Wikilivres
frwikibooks
https://fr.wikibooks.org/wiki/Accueil
MediaWiki 1.45.0-wmf.4
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
Livre de cuisine/Poulet yassa
0
28170
744098
744029
2025-06-04T09:23:14Z
JackPotte
5426
Révocation de 2 modifications réalisées par [[Special:Contributions/165.169.239.238|165.169.239.238]] ([[User talk:165.169.239.238|discussion]]) et restauration de la dernière version réalisée par [[User:JackPotte|JackPotte]]
329376
wikitext
text/x-wiki
{{Livre de cuisine}}
* Pour : 5 personnes
* Durée : 2 h 15
* Difficulté : facile
== Ingrédients ==
* 1 {{i|poulet}}
* 5 gros {{i|'=oui|oignon}}s
* 1 gousse d'{{i|'=oui|ail}}
* 1 verre de {{i|moutarde}}
* sel
* {{i|poivre}}
* {{i|noix de muscade}}
* 3 cubes de {{i|bouillon}} de volaille
* {{i|'=oui|huile d'arachide}}
== Préparation ==
# Préparer tous les ingrédients
# Découper le poulet en 10 morceaux.
# Mettre les morceaux de poulet, 1 gousse d'ail haché, 1 cube de bouillon de volaille émietté, la moitié du verre de moutarde, le sel, le poivre et la noix de muscade dans une casserole.
# Bien mélanger et laisser mariner 30 minutes.
# Placer les morceaux de poulet marinés sur une plaque allant au four et faire griller le poulet pendant une trentaine de minutes sous le grill du four.
# Émincer les oignons et hacher l'ail restant.
# Les faire suer à l'huile d'arachide pendant quelques minutes.
# Baisser le feu afin que les oignons ne colorent pas.
# Ajouter les morceaux de poulet grillé.
# Ajouter le restant de moutarde et mélanger.
# Mouiller à l'eau froide jusqu'à hauteur du poulet.
# Laisser mijoter une quinzaine de minutes.
# Ajouter les deux cubes de bouillon de volaille restants.
# Laisser cuire une trentaine de minutes à petit frémissement.
# Au terme de la cuisson, rectifier l'assaisonnement.
# Le Yassa se sert avec du riz blanc, de la banane ou de l'igname bouilli.
[[Catégorie:Recettes de tous les jours|Poulet Yassa]]
nk6yr4f5onumrhvvuvcikqd3j05urgj
Astrologie/Préliminaires astronomiques/La mesure du temps/Le temps stellaire et le temps sidéral
0
55241
744064
415014
2025-06-03T18:56:24Z
Kad'Astres
30330
744064
wikitext
text/x-wiki
{{SI|refonte totale}}
temps stellaire // culmination d'une étoile de référence
temps sidéral // culmination du point vernal (assimilé à une étoile)
[[Catégorie:Astrologie]]
czwztj0zp5smgorjasyvvl9iw44sq1y
744074
744064
2025-06-03T19:14:46Z
Kad'Astres
30330
744074
wikitext
text/x-wiki
{{SI|refonte totale}}
ehonxiv34l7r9zetnh7ih45v7soi8vy
Astrologie/Préliminaires astronomiques/La mesure du temps/Le jour sidéral et le jour solaire vrai
0
55242
744065
415013
2025-06-03T18:57:21Z
Kad'Astres
30330
744065
wikitext
text/x-wiki
{{SI|refonte totale}}
jour sidéral // temps requis pour revenir à angle droit d'une étoile
jour solaire vrai : prise en compte du Soleil comme une étoile particulière
[[Catégorie:Astrologie]]
idpyez2k0r7rciogenbwkhtq418jq3p
744073
744065
2025-06-03T19:14:23Z
Kad'Astres
30330
744073
wikitext
text/x-wiki
{{SI|refonte totale}}
ehonxiv34l7r9zetnh7ih45v7soi8vy
Astrologie/Préliminaires astronomiques/La mesure du temps/Le jour solaire moyen et l'équation du temps
0
55243
744066
743751
2025-06-03T18:58:12Z
Kad'Astres
30330
744066
wikitext
text/x-wiki
{{SI|refonte totale}}
jour solaire moyen: prise en compte d'un Soleil fictif (à marche régulière)
équation du temps : passage du temps du Soleil moyen (celui de nos horloges, avec une marche régulière de 24 heures) au temps des cadrans solaires (celui mesuré par la position réelle du Soleil dans le ciel : temps solaire vrai), et réciproquement.
[[Catégorie:Astrologie]]
1bnjbfbyjkrrr9vk6uj4kdw4zdfxsfu
744072
744066
2025-06-03T19:14:00Z
Kad'Astres
30330
Contenu remplacé par « {{SI|refonte totale}} »
744072
wikitext
text/x-wiki
{{SI|refonte totale}}
ehonxiv34l7r9zetnh7ih45v7soi8vy
Jeu de rôle sur table — Jouer, créer
0
67338
744046
741215
2025-06-03T15:21:58Z
Cdang
1202
/* Sommaire */ avancement
744046
wikitext
text/x-wiki
{{Page de garde|image=Dadosvariasfaces.jpg|imagedesc=Différents types de dés utilisés dans les jeux de rôle|description=
''Ce wikilivre se trouve sur l'étagère [[Wikilivres:Étagère jeu|Jeu]].''
Ce wikilivre est consacré au '''jeu de rôle sur table''', ou '''jeu de rôle papier'''. Il concerne la manière de jouer, sans s'attacher à un jeu particulier, ainsi que la création en jeu de rôle, et la création de jeu de rôle : création « émergente » en cours de jeu (improvisation), création de scénario et de manière plus générale préparation de partie, création d'un cadre (univers fictionnel), création de règles (règles maison ou système complet).
|avancement=Avancé
|cdu=
* {{CDU item|7|79}}
}}
== Sommaire ==
# [[/Qu'est-ce que le jeu de rôle ?/]] {{100}}
# [[/Ma première partie/]] {{100}}
# [[/Bienveillance et contrat ludique/]] {{100}}
# [[/Préparer une partie/]] {{100}}
# [[/Au cœur du jeu de rôle, la partie/]] {{100}}
# [[/Créer une nouvelle règle/]] {{100}}
# [[/Le hasard dans les jeux de rôle/]] {{100}}
# [[/Probabilités des dés en jeu de rôle/]] {{100}}
# [[/Créer des éléments du monde/]] {{75}}
# [[/Créer un univers/]] {{75}}
# [[/Créer un jeu de rôle/]] {{25}}
; Annexe
# [[/Abréviations/]] {{100}}
== Voir aussi ==
=== Bibliographie ===
* {{ouvrage
| prénom1 = Pierre | nom1 = Rosenthal | responsabilité1 = rédacteur en chef
| et al. = oui
| titre = Manuel pratique du jeu de rôle
| collection = Casus Belli | numéro dans collection = HS25
| mois = mai | année = 1999
| éditeur = Excelsior publications
| pages = 98
| format = A4
| lire en ligne = https://docs.google.com/file/d/0B-GYyxhvMf-PaUQ4OUVUMkFFQlk/
| présentation en ligne = http://www.ffjdr.org/manuel-pratique-du-jeu-de-role/
}}
* {{ouvrage
| langue = fr
| prénom1 = Coralie | nom1 = David | directeur1 = oui
| prénom2 = Jérôme | nom2 = Larré | directeur2 = oui
| et al. = oui
| titre = Mener des parties de jeu de rôle
| éditeur = Lapin Marteau
| collection = Sortit de l'auberge | numéro dans collection = 1
| année = 2016
| isbn = 978-2-9545811-4-9
| pages = 400
| format = 16,5 × 24 cm
| présentation en ligne = http://www.lapinmarteau.com/jeux-et-accessoires-sortir-de-lauberge-01-mener-des-parties-de-jeu-de-role/
}}
* {{ouvrage
| langue = fr
| prénom1 = Coralie | nom1 = David | directeur1 = oui
| prénom2 = Jérôme | nom2 = Larré | directeur2 = oui
| et al. = oui
| titre = Jouer des parties de jeu de rôle
| éditeur = Lapin Marteau
| collection = Sortit de l'auberge | numéro dans collection = 2
| année = 2016
| isbn = 978-2-9545811-6-3
| pages = 368
| format = 16,5 × 24 cm
| présentation en ligne = http://www.lapinmarteau.com/jeux-et-accessoires-sortir-de-lauberge-02-jouer-des-parties-de-jeu-de-role/
}}
=== Autres wikilivres ===
* [[Guide pour jeux de rôles]]
=== Liens externes ===
* Tartofrez, le blog de [[w:Jérôme Larré|Jérôme « Brand » Larré]] :
** {{lien web | url = http://www.tartofrez.com/category/5-trucs/ | titre = Rubrique « Cinq trucs »}} ;
** {{lien web | url = http://www.tartofrez.com/category/game-design/ | titre = Rubrique « Créer son jeu ''(game design)'' »}} ;
** {{lien web | url = http://www.tartofrez.com/category/theorie/ | titre = Rubrique « Théorie et ''game design'' »}}.
* {{lien web | url = http://awarestudios.blogspot.co.uk/ | titre = Du bruit derrière le paravent}}, le blog de Grégory « Gregopor » Pogorzelski.
* {{lien web | url = http://ptgptb.fr/ | titre = ''Places to go, people to be'' (ptgptb)}}, traduction d'articles sur le jeu de rôle.
** {{lien web | url = https://ptgptb.fr/accueil-ebooks | titre=Ebooks | site=ptgptb |consulté le=2023-03-09}}.
* {{lien web | url = http://www.limbicsystemsjdr.com/ | titre = Limbic System}}, le blog de Frédéric Sintes.
* {{lien web | url = http://www.legrog.org/ | titre = Le Guide du rôliste galactique (GRoG/roliste.com)}}, la plus grande base de données francophone de jeux de rôle.
* {{lien web | url = https://courantsalternatifs.fr/forum/viewtopic.php?f=18&t=1209&p=7979#p7979 | titre = JdR et animation | auteur = Thomas Munier | site = Courants alternatifs | date = 2018-08-21 | consulté le = 2018-08-21}}
* Podcasts :
** {{lien web | url = http://www.cendrones.fr/ | titre = Cendrones, la Voix D'altaride}}.
** {{lien web | url = http://www.lacellule.net/ | titre = La Cellule}}.
* Forums
** {{lien web | url = http://www.subasylum.com/ | titre = Antonio Bay, village rôliste}}
** {{lien web | url = http://www.pandapirate.net/casus/ | titre = Casus non officiel}}
** {{lien web | url = http://forum.opale-roliste.com/ | titre = Opale rôliste}}
** {{lien web | url = http://www.reves-d-ailleurs.eu/ | titre = Rêves d'ailleurs}}
* logiciels et sites d'aide en jeu et tables virtuelles :
** {{lien web | url = https://www.fantasygrounds.com/home/home.php | titre = Fantasy Grounds }}
** {{lien web | url = https://kanka.io/fr | titre = Kanka }}
** {{lien web | url = https://lets-role.com/ | titre = Let's Role }}
** {{lien web | url = https://rolisteam.org/ | titre = Rolisteam }}
** {{lien web | url = https://roll20.net/ | titre = Roll20 }}
* jeux en licence libre :
** {{lien web | url = https://ogc.rpglibrary.org/index.php?title=Main_Page | titre = OGC (RPG Library Open Game Content Repository) consulté le=2025-03-20 }}
** {{lien web | url = https://legrumph.org/Terrier/public/jeux-complets | titre = Jeux de rôle complets |site=Le Terrier du Grümph | consulté le=2025-03-20 }}
{{DEFAULTSORT:Jeu de role sur table Jouer creer}}
[[Catégorie:Jeu de rôle sur table — Jouer, créer (livre)|*]]
ak2lqzn75wfdrtp4i3jmb7wj4jf87u6
Les cartes graphiques/Les cartes accélératrices 3D
0
67392
744096
735604
2025-06-04T00:17:00Z
Mewtow
31375
/* Les unités de texture sont intégrées aux processeurs de shaders */
744096
wikitext
text/x-wiki
Il est intéressant d'étudier le hardware des cartes graphiques en faisant un petit résumé de leur évolution dans le temps. En effet, leur hardware a fortement évolué dans le temps. Et il serait difficile à comprendre le hardware actuel sans parler du hardware d'antan. En effet, une carte graphique moderne est partiellement programmable. Certains circuits sont totalement programmables, d'autres non. Et pour comprendre pourquoi, il faut étudier comment ces circuits ont évolués.
Le hardware des cartes graphiques a fortement évolué dans le temps, ce qui n'est pas une surprise. Les évolutions de la technologie, avec la miniaturisation des transistors et l'augmentation de leurs performances a permit aux cartes graphiques d'incorporer de plus en plus de circuits avec les années. Avant l'invention des cartes graphiques, toutes les étapes du pipeline graphique étaient réalisées par le processeur : il calculait l'image à afficher, et l’envoyait à une carte d'affichage 2D. Au fil du temps, de nombreux circuits furent ajoutés, afin de déporter un maximum de calculs vers la carte vidéo.
Le rendu 3D moderne est basé sur le placage de texture inverse, avec des coordonnées de texture, une correction de perspective, etc. Mais les anciennes consoles et bornes d'arcade utilisaient le placage de texture direct. Et cela a impacté le hardware des consoles/PCs de l'époque. Avec le placage de texture direct, il était primordial de calculer la géométrie, mais la rasterisation était le fait de VDC améliorés. Aussi, les premières bornes d'arcade 3D et les consoles de 5ème génération disposaient processeurs pour calculer la géométrie et de circuits d'application de textures très particuliers. A l'inverse, les PC utilisaient un rendu inverse, totalement différent. Sur les PC, les premières cartes graphiques avaient un circuit de rastérisation et des unités de textures, mais pas de circuits géométriques.
==Les précurseurs : les cartes graphiques des bornes d'arcade==
[[File:Sega ST-V Dynamite Deka PCB 20100324.jpg|vignette|Sega ST-V Dynamite Deka PCB 20100324]]
L'accélération du rendu 3D sur les bornes d'arcade était déjà bien avancé dès les années 90. Les bornes d'arcade ont toujours été un segment haut de gamme de l'industrie du jeu vidéo, aussi ce n'est pas étonnant. Le prix d'une borne d'arcade dépassait facilement les 10 000 dollars pour les plus chères et une bonne partie du prix était celui du matériel informatique. Le matériel était donc très puissant et débordait de mémoire RAM comparé aux consoles de jeu et aux PC.
La plupart des bornes d'arcade utilisaient du matériel standardisé entre plusieurs bornes. A l'intérieur d'une borne d'arcade se trouve une '''carte de borne d'arcade''' qui est une carte mère avec un ou plusieurs processeurs, de la RAM, une carte graphique, un VDC et pas mal d'autres matériels. La carte est reliée aux périphériques de la borne : joysticks, écran, pédales, le dispositif pour insérer les pièces afin de payer, le système sonore, etc. Le jeu utilisé pour la borne est placé dans une cartouche qui est insérée dans un connecteur spécialisé.
Les cartes de bornes d'arcade étaient généralement assez complexes, elles avaient une grande taille et avaient plus de composants que les cartes mères de PC. Chaque carte contenait un grand nombre de chips pour la mémoire RAM et ROM, et il n'était pas rare d'avoir plusieurs processeurs sur une même carte. Et il n'était pas rare d'avoir trois à quatre cartes superposées dans une seule borne. Pour ceux qui veulent en savoir plus, Fabien Sanglard a publié gratuitement un livre sur le fonctionnement des cartes d'arcade CPS System, disponible via ce lien : [https://fabiensanglard.net/b/cpsb.pdf The book of CP System].
Les premières cartes graphiques des bornes d'arcade étaient des cartes graphiques 2D auxquelles on avait ajouté quelques fonctionnalités. Les sprites pouvaient être tournés, agrandit/réduits, ou déformés pour simuler de la perspective et faire de la fausse 3D. Par la suite, le vrai rendu 3D est apparu sur les bornes d'arcade, avec des GPUs totalement programmables.
Dès 1988, la carte d'arcade Namco System 21 et Sega Model 1 géraient les calculs géométriques. Les deux cartes n'utilisaient pas de circuit géométrique fixe, mais l'émulaient avec un processeur programmé avec un programme informatique qui implémentait le T&L en logiciel. Elles utilisaient plusieurs DSP pour ce faire.
Quelques années plus tard, les cartes graphiques se sont mises à supporter un éclairage de Gouraud et du placage de texture. Le Namco System 22 et la Sega model 2 supportaient des textures 2D et comme le filtrage de texture (bilinéaire et trilinéaire), le mip-mapping, et quelques autres. Par la suite, elles ont réutilisé le hardware des PC et autres consoles de jeux.
==La 3D sur les consoles de quatrième génération==
Les consoles avant la quatrième génération de console étaient des consoles purement 2D, sans circuits d'accélération 3D. Leur carte graphique était un simple VDC 2D, plus ou moins performant selon la console. Pourtant, les consoles de quatrième génération ont connus quelques jeux en 3D. Par exemple, les jeux Star Fox sur SNES. Fait important, il s'agissait de vrais jeux en 3D qui tournaient sur des consoles qui ne géraient pas la 3D. La raison à cela est que la 3D était calculée par un GPU placé dans les cartouches du jeu !
Par exemple, les cartouches de Starfox et de Super Mario 2 contenait un coprocesseur Super FX, qui gérait des calculs de rendu 2D/3D. Un autre exemple est celui du co-processeur Cx4, cousin du Super FX, qui était spécialisé dans les calculs trigonométriques et diverses opérations utiles pour le rendu 2D/3D. En tout, il y a environ 16 coprocesseurs pour la SNES et on en trouve facilement la liste sur le net.
La console était conçue pour, des pins sur les ports cartouches étaient prévues pour des fonctionnalités de cartouche annexes, dont ces coprocesseurs. Ces pins connectaient le coprocesseur au bus des entrées-sorties. Les coprocesseurs des cartouches de NES avaient souvent de la mémoire rien que pour eux, qui était intégrée dans la cartouche.
==L'arrivée des consoles de cinquième génération==
Par la suite, les consoles de jeu se sont mises à intégrer des cartes graphiques 3D. Les premières consoles de jeu capables de rendu 3D sont les consoles dites de 5ème génération. Il y a diverses manières de classer les consoles en générations, la plus commune place la 3D à la 5ème génération, mais détailler ces controverses quant à ce classement nous amènerait trop loin. Les cartes graphiques des consoles de jeu utilisaient le rendu inverse, avec quelques exceptions qui utilisaient le rendu inverse.
===La Nintendo 64 : un GPU avancé===
La Nintendo 64 avait le GPU le plus complexe comparé aux autres consoles, et dépassait même les cartes graphiques des PC. Son GPU était très novateur pour une console sortie en 1996. Il incorporait une unité pour les calculs géométriques, un circuit pour la rasterisation, une unité pour les textures et un ROP final pour les calculs de transparence/brouillard/anti-aliasing, ainsi qu'un circuit pour gérer la profondeur des pixels. En somme, tout le pipeline graphique était implémenté dans le GPU de la Nintendo 64, chose très en avance sur son temps, même comparé au PC !
Le GPU est construit autour d'un processeur dédié aux calculs géométriques, le ''Reality Signal Processor'' (RSP), autour duquel on a ajouté des circuits pour le reste du pipeline graphique. L'unité de calcul géométrique est un processeur MIPS R4000, un processeur assez courant à l'époque, mais auquel on avait retiré quelques fonctionnalités inutiles pour le rendu 3D. Il était couplé à 4 KB de mémoire vidéo, ainsi qu'à 4 KB de mémoire ROM. Le reste du GPU était réalisé avec des circuits fixes. La Nintendo 64 utilisait déjà un mélange de circuits programmables et fixes.
Un point intéressant est que le programme exécuté par le RSP pouvait être programmé ! Le RSP gérait déjà des espèces de proto-shaders, qui étaient appelés des ''[https://ultra64.ca/files/documentation/online-manuals/functions_reference_manual_2.0i/ucode/microcode.html micro-codes]'' dans la documentation de l'époque. La ROM associée au RSP mémorise cinq à sept programmes différents, des microcodes de base, aux fonctionnalités différentes. Ils géraient le rendu 3D de manière différente et avec une gestion des ressources différentes. Très peu de studios de jeu vidéo ont développé leur propre microcodes N64, car la documentation était mal faite, que Nintendo ne fournissait pas de support officiel pour cela, que les outils de développement ne permettaient pas de faire cela proprement et efficacement.
===La Playstation 1===
Sur la Playstation 1 le calcul de la géométrie était réalisé par le processeur, la carte graphique gérait tout le reste. Et la carte graphique était un circuit fixe spécialisé dans la rasterisation et le placage de textures. Elle utilisait, comme la Nintendo 64, le placage de texture inverse, qui est apparu ensuite sur les cartes graphiques.
===La 3DO et la Sega Saturn===
La Sega Saturn et la 3DO étaient les deux seules consoles à utiliser le rendu direct. La géométrie était calculée sur le processeur, même si les consoles utilisaient parfois un CPU dédié au calcul de la géométrie. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures.
La Sega Saturn incorpore trois processeurs et deux GPU. Les deux GPUs sont nommés le VDP1 et le VDP2. Le VDP1 s'occupe des textures et des sprites, le VDP2 s'occupe uniquement de l'arrière-plan et incorpore un VDC tout ce qu'il y a de plus simple. Ils ne gèrent pas du tout la géométrie, qui est calculée par les trois processeurs.
Le troisième processeur, la Saturn Control Unit, est un processeur de type DSP, à savoir un processeur spécialisé dans le traitement de signal. Il est utilisé presque exclusivement pour accélérer les calculs géométriques. Il avait sa propre mémoire RAM dédiée, 32 KB de SRAM, soit une mémoire locale très rapide. Les transferts entre cette RAM et le reste de l'ordinateur était géré par un contrôleur DMA intégré dans le DSP. En somme, il s'agit d'une sorte de processeur spécialisé dans la géométrie, une sorte d'unité géométrique programmable. Mais la géométrie n'était pas forcément calculée que sur ce DSP, mais pouvait être prise en charge par les 3 CPU.
==L'historique des cartes graphiques des PC, avant l'arrivée de Direct X et Open Gl==
Sur PC, l'évolution des cartes graphiques a eu du retard par rapport aux consoles. Les PC sont en effet des machines multi-usage, pour lesquelles le jeu vidéo était un cas d'utilisation parmi tant d'autres. Et les consoles étaient la plateforme principale pour jouer à des jeux vidéo, le jeu vidéo PC étant plus marginal. Mais cela ne veut pas dire que le jeu PC n'existait pas, loin de là !
Un problème pour les jeux PC était que l'écosystème des PC était aussi fragmenté en plusieurs machines différentes : machines Apple 1 et 2, ordinateurs Commdore et Amiga, IBM PC et dérivés, etc. Aussi, programmer des jeux PC n'était pas mince affaire, car les problèmes de compatibilité étaient légion. C'est seulement quand la plateforme x86 des IBM PC s'est démocratisée que l'informatique grand public s'est standardisée, réduisant fortement les problèmes de compatibilité. Mais cela n'a pas suffit, il a aussi fallu que les API 3D naissent.
Les API 3D comme Direct X et Open GL sont absolument cruciales pour garantir la compatibilité entre plusieurs ordinateurs aux cartes graphiques différentes. Aussi, l'évolution des cartes graphiques pour PC s'est faite main dans la main avec l'évolution des API 3D. Il a fallu que Direct X et Open GL progressent suffisamment pour que les problèmes de compatibilité soient partiellement résolus. Les fonctionnalités des cartes graphiques ont évolué dans le temps, en suivant les évolutions des API 3D. Du moins dans les grandes lignes, car il est arrivé plusieurs fois que des fonctionnalités naissent sur les cartes graphiques, pour que les fabricants forcent la main de Microsoft ou d'Open GL pour les intégrer de force dans les API 3D. Passons.
===L'introduction des premiers jeux 3D : Quake et les drivers miniGL===
L'histoire de la 3D sur PC commence avec la sortie du jeu Quake, d'IdSoftware. Celui-ci pouvait fonctionner en rendu logiciel, mais le programmeur responsable du moteur 3D (le fameux John Carmack) ajouta une version OpenGL du jeu. Le fait que le jeu était programmé sur une station de travail compatible avec OpenGL faisait que ce choix n'était si stupide, même si aucune carte accélératrice de l'époque ne supportait OpenGL. C'était là un choix qui se révéla visionnaire. En théorie, le rendu par OpenGL aurait dû se faire intégralement en logiciel, sauf sur quelques rares stations de travail adaptées. Mais les premières cartes graphiques étaient déjà dans les starting blocks.
La toute première carte 3D pour PC est la Rendition Vérité V1000, sortie en Septembre 1995, soit quelques mois avant l'arrivée de la Nintendo 64. La Rendition Vérité V1000 était purement programmable, contrairement aux autres cartes graphiques de l'époque. Elle contenait un processeur MIPS cadencé à 25 MHz, 4 mébioctets de RAM, une ROM pour le BIOS, et un RAMDAC, rien de plus. C'était un vrai ordinateur complètement programmable de bout en bout. Les programmeurs ne pouvaient cependant pas utiliser cette programmabilité avec des ''shaders'', mais elle permettait à Rendition d'implémenter n'importe quelle API 3D, que ce soit OpenGL, DirectX ou même sa son API propriétaire.
La Rendition Vérité avait de bonnes performances pour ce qui est de la géométrie, mais pas pour le reste. Autant les calculs géométriques sont assez rapides quand on les exécute sur un CPU, autant réaliser la rastérisation et le placage de texture en logiciel n'est pas efficace, pareil pour les opérations de fin de pipeline comme l'antialiasing. Le manque d'unités fixes très rapides pour la rastérisation, le placage de texture ou les opérations de fin de pipeline était clairement un gros défaut. Les autres cartes graphiques avaient implémenté l'exact inverse : de bonnes performances pour le placage de textures et la rastérization, mais les calculs géométriques étaient réalisés par le CPU. Au final, la carte graphique qui s'en sortait le mieux était la Nintendo 64 qui avait un CPU dédié pour les calculs géométriques et des circuits fixes pour le reste...
Les autres cartes graphiques étaient totalement non-programmables et ne contenant que des circuits fixes, regroupe les Voodoo de 3dfx, les Riva TNT de NVIDIA, les Rage/3D d'ATI, la Virge/3D de S3, et la Matrox Mystique. Elles contenaient des circuits pour gérer les textures, mais aussi une étape d'enregistrement des pixels en mémoire. Elle était gérait le ''z-buffer'' en mémoire vidéo, mais aussi quelques effets graphiques comme les effets de brouillard. L'unité d'enregistrement des pixels en mémoire s'appelle le ROP pour ''Raster Operation Pipeline''.
[[File:Architecture de base d'une carte 3D - 2.png|centre|vignette|upright=1.5|Carte 3D sans rasterization matérielle.]]
Les cartes suivantes ajoutèrent une gestion des étapes de ''rasterization'' directement en matériel. Les cartes ATI rage 2, les Invention de chez Rendition, et d'autres cartes graphiques supportaient la rasterisation en hardware.
[[File:Architecture de base d'une carte 3D - 3.png|centre|vignette|upright=1.5|Carte 3D avec gestion de la géométrie.]]
Pour exploiter les unités de texture et le circuit de rastérisation, OpenGL et Direct 3D étaient partiellement implémentées en logiciel, car les cartes graphiques ne supportaient pas toutes les fonctionnalités de l'API. C'était l'époque du miniGL, des implémentations partielles d'OpenGL, fournies par les fabricants de cartes 3D, implémentées dans les pilotes de périphériques de ces dernières. Les fonctionnalités d'OpenGL implémentées dans ces pilotes étaient presque toutes exécutées en matériel, par la carte graphique. Avec l'évolution du matériel, les pilotes de périphériques devinrent de plus en plus complets, au point de devenir des implémentations totales d'OpenGL.
Mais au-delà d'OpenGL, chaque fabricant de carte graphique avait sa propre API propriétaire, qui était gérée par leurs pilotes de périphériques (''drivers''). Par exemple, les premières cartes graphiques de 3dfx interactive, les fameuses voodoo, disposaient de leur propre API graphique, l'API Glide. Elle facilitait la gestion de la géométrie et des textures, ce qui collait bien avec l'architecture de ces cartes 3D. Mais ces API propriétaires tombèrent rapidement en désuétude avec l'évolution de DirectX et d'OpenGL.
Direct X était une API dans l'ombre d'Open GL. La première version de Direct X qui supportait la 3D était DirectX 2.0 (juin 2, 1996), suivie rapidement par DirectX 3.0 (septembre 1996). Elles dataient d'avant le jeu Quake, et elles étaient très éloignées du hardware des premières cartes graphiques. Elles utilisaient un système d'''execute buffer'' pour communiquer avec la carte graphique, Microsoft espérait que le matériel 3D implémenterait ce genre de système. Ce qui ne fu pas le cas.
Direct X 4.0 a été abandonné en cours de développement pour laisser à une version 5.0 assez semblable à la 2.0/3.0. Le mode de rendu laissait de côté les ''execute buffer'' pour coller un peu plus au hardware de l'époque. Mais rien de vraiment probant comparé à Open GL. Même Windows utilisait Open GL au lieu de Direct X maison... C'est avec Direct X 6.0 que Direct X est entré dans la cours des grands. Il gérait la plupart des technologies supportées par les cartes graphiques de l'époque.
===Le ''multi-texturing'' de l'époque Direct X 6.0 : combiner plusieurs textures===
Une technologie très importante standardisée par Dirext X 6 est la technique du '''''multi-texturing'''''. Avec ce qu'on a dit dans le chapitre précédent, vous pensez sans doute qu'il n'y a qu'une seule texture par objet, qui est plaquée sur sa surface. Mais divers effet graphiques demandent d'ajouter des textures par dessus d'autres textures. En général, elles servent pour ajouter des détails, du relief, sur une surface pré-existante.
Un exemple intéressant vient des jeux de tir : ajouter des impacts de balles sur les murs. Pour cela, on plaque une texture d'impact de balle sur le mur, à la position du tir. Il s'agit là d'un exemple de '''''decals''''', des petites textures ajoutées sur les murs ou le sol, afin de simuler de la poussière, des impacts de balle, des craquelures, des fissures, des trous, etc. Les textures en question sont de petite taille et se superposent à une texture existante, plus grande. Rendre des ''decals'' demande de pouvoir superposer deux textures.
Direct X 6.0 supportait l'application de plusieurs textures directement dans le matériel. La carte graphique devait être capable d'accéder à deux textures en même temps, ou du moins faire semblant que. Pour cela, elle doublaient les unités de texture et adaptaient les connexions entre unités de texture et mémoire vidéo. La mémoire vidéo devait être capable de gérer plusieurs accès mémoire en même temps et devait alors avoir un débit binaire élevé.
[[File:Multitexturing.png|centre|vignette|upright=2|Multitexturing]]
La carte graphique devait aussi gérer de quoi combiner deux textures entre elles. Par exemple, pour revenir sur l'exemple d'une texture d'impact de balle, il faut que la texture d'impact recouvre totalement la texture du mur. Dans ce cas, la combinaison est simple : la première texture remplace l'ancienne, là où elle est appliquée. Mais les cartes graphiques ont ajouté d'autres combinaisons possibles, par exemple additionner les deux textures entre elle, faire une moyenne des texels, etc.
Les opérations pour combiner les textures était le fait de circuits appelés des '''''combiners'''''. Concrètement, les ''combiners'' sont de simples unités de calcul. Les ''conbiners'' ont beaucoup évolués dans le temps, mais les premières implémentation se limitaient à quelques opérations simples : addition, multiplication, superposition, interpolation. L'opération effectuer était envoyée au ''conbiner'' sur une entrée dédiée.
[[File:Multitexturing avec combiners.png|centre|vignette|upright=2|Multitexturing avec combiners]]
S'il y avait eu un seul ''conbiner'', le circuit de ''multitexturing'' aurait été simplement configurable. Mais dans la réalité, les premières cartes utilisant du ''multi-texturing'' utilisaient plusieurs ''combiners'' placés les uns à la suite des autres. L'implémentation des ''combiners'' retenue par Open Gl, et par le hardware des cartes graphiques, était la suivante. Les ''combiners'' étaient placés en série, l'un à la suite de l'autre, chacun combinant le résultat de l'étage précédent avec une texture. Le premier ''combiner'' gérait l'éclairage par sommet, afin de conserver un minimum de rétrocompatibilité.
[[File:Texture combiners Open GL.png|centre|vignette|upright=2|Texture combiners Open GL]]
Voici les opérations supportées par les ''combiners'' d'Open GL. Ils prennent en entrée le résultat de l'étage précédent et le combinent avec une texture lue depuis l'unité de texture.
{|class="wikitable"
|+ Opérations supportées par les ''combiners'' d'Open GL
|-
! Replace
| colspan="2" | Pixel provenant de l'unité de texture
|-
! Addition
| colspan="2" | Additionne l'entrée au texel lu.
|-
! Modulate
| colspan="2" | Multiplie l'entrée avec le texel lu
|-
! Mélange (''blending'')
| Moyenne pondérée des deux entrées, pondérée par la composante de transparence || La couleur de transparence du texel lu et de l'entrée sont multipliées.
|-
! Decals
| Moyenne pondérée des deux entrées, pondérée par la composante de transparence. || La transparence du résultat est celle de l'entrée.
|}
Il faut noter qu'un dernier étage de ''combiners'' s'occupait d'ajouter la couleur spéculaire et les effets de brouillards. Il était à part des autres et n'était pas configurable, c'était un étage fixe, qui était toujours présent, peu importe le nombre de textures utilisé. Il était parfois appelé le '''''combiner'' final''', terme que nous réutiliserons par la suite.
Mine de rien, cela a rendu les cartes graphiques partiellement programmables. Le fait qu'il y ait des opérations enchainées à la suite, opérations qu'on peut choisir librement, suffit à créer une sorte de mini-programme qui décide comment mélanger plusieurs textures. Mais il y avait une limitation de taille : le fait que les données soient transmises d'un étage à l'autre, sans détours possibles. Par exemple, le troisième étage ne pouvait avoir comme seule opérande le résultat du second étage, mais ne pouvait pas utiliser celui du premier étage. Il n'y avait pas de registres pour stocker ce qui sortait de la rastérisation, ni pour mémoriser temporairement les texels lus.
===Le ''Transform & Lighting'' matériel de Direct X 7.0===
[[File:Architecture de base d'une carte 3D - 4.png|vignette|upright=1.5|Carte 3D avec gestion de la géométrie.]]
La première carte graphique pour PC capable de gérer la géométrie en hardware fût la Geforce 256, la toute première Geforce. Son unité de gestion de la géométrie n'est autre que la bien connue '''unité T&L''' (''Transform And Lighting''). Elle implémentait des algorithmes d'éclairage de la scène 3D assez simples, comme un éclairage de Gouraud, qui étaient directement câblés dans ses circuits. Mais contrairement à la Nintendo 64 et aux bornes d'arcade, elle implémentait le tout, non pas avec un processeur classique, mais avec des circuits fixes.
Avec Direct X 7.0 et Open GL 1.0, l'éclairage était en théorie limité à de l'éclairage par sommet, l'éclairage par pixel n'était pas implémentable en hardware. Les cartes graphiques ont tenté d'implémenter l'éclairage par pixel, mais cela n'est pas allé au-delà du support de quelques techniques de ''bump-mapping'' très limitées. Par exemple, Direct X 6.0 implémentait une forme limitée de ''bump-mapping'', guère plus.
Un autre problème est que l'éclairage peut s'implémenter de plusieurs manières différentes, aux résultats visuels différents. Les unités de T&L étaient souvent en retard sur les algorithmes logiciels. Les programmeurs avaient le choix entre programmer les algorithmes d’éclairage qu'ils voulaient et les exécuter en logiciel ou utiliser ceux de l'unité de T&L, et choisissaient souvent la première option. Par exemple, Quake 3 Arena et Unreal Tournament n'utilisaient pas les capacités d'éclairage géométrique et préféraient utiliser leurs calculs d'éclairage logiciel fait maison.
Cependant, le hardware dépassait les capacités des API et avait déjà commencé à ajouter des capacités de programmation liées au ''multi-texturing''. Les cartes graphiques de l'époque, surtout chez NVIDIA, implémentaient un système de '''''register combiners''''', une forme améliorée de ''texture combiners'', qui permettait de faire une forme limitée d'éclairage par pixel, notamment du vrai ''bump-mampping'', voire du ''normal-mapping''. Mais ce n'était pas totalement supporté par les API 3D de l'époque.
Les ''registers combiners'' sont des ''texture combiners'' mais dans lesquels ont aurait retiré la stricte organisation en série. Il y a toujours plusieurs étages à la suite, qui peuvent exécuter chacun une opération, mais tous les étages ont maintenant accès à toutes les textures lues et à tout ce qui sort de la rastérisation, pas seulement au résultat de l'étape précédente. Pour cela, on ajoute des registres pour mémoriser ce qui sort des unités de texture, et pour ce qui sort de la rastérisation. De plus, on ajoute des registres temporaires pour mémoriser les résultats de chaque ''combiner'', de chaque étage.
Il faut cependant signaler qu'il existe un ''combiner'' final, séparé des étages qui effectuent des opérations proprement dits. Il s'agit de l'étage qui applique la couleur spéculaire et les effets de brouillards. Il ne peut être utilisé qu'à la toute fin du traitement, en tant que dernier étage, on ne peut pas mettre d'opérations après lui. Sa sortie est directement connectée aux ROPs, pas à des registres. Il faut donc faire la distinction entre les '''''combiners'' généraux''' qui effectuent une opération et mémorisent le résultat dans des registres, et le ''combiner'' final qui envoie le résultat aux ROPs.
L'implémentation des ''register combiners'' utilisait un processeur spécialisés dans les traitements sur des pixels, une sorte de proto-processeur de ''shader''. Le processeur supportait des opérations assez complexes : multiplication, produit scalaire, additions. Il s'agissait d'un processeur de type VLIW, qui sera décrit dans quelques chapitres. Mais ce processeur avait des programmes très courts. Les premières cartes NVIDIA, comme les cartes TNT pouvaient exécuter deux opérations à la suite, suivie par l'application de la couleurs spéculaire et du brouillard. En somme, elles étaient limitées à un ''shader'' à deux/trois opérations, mais c'était un début. Le nombre d'opérations consécutives est rapidement passé à 8 sur la Geforce 3.
===L'arrivée des ''shaders'' avec Direct X 8.0===
[[File:Architecture de la Geforce 3.png|vignette|upright=1.5|Architecture de la Geforce 3]]
Les ''register combiners'' était un premier pas vers un éclairage programmable. Paradoxalement, l'évolution suivante s'est faite non pas dans l'unité de rastérisation/texture, mais dans l'unité de traitement de la géométrie. La Geforce 3 a remplacé l'unité de T&L par un processeur capable d'exécuter des programmes. Les programmes en question complétaient l'unité de T&L, afin de pouvoir rajouter des techniques d'éclairage plus complexes. Le tout a permis aussi d'ajouter des animations, des effets de fourrures, des ombres par ''shadow volume'', des systèmes de particule évolués, et bien d'autres.
À partir de la Geforce 3 de Nvidia, les cartes graphiques sont devenues capables d'exécuter des programmes appelés '''''shaders'''''. Le terme ''shader'' vient de ''shading'' : ombrage en anglais. Grace aux ''shaders'', l'éclairage est devenu programmable, il n'est plus géré par des unités d'éclairage fixes mais été laissé à la créativité des programmeurs. Les programmeurs ne sont plus vraiment limités par les algorithmes d'éclairage implémentés dans les cartes graphiques, mais peuvent implémenter les algorithmes d'éclairage qu'ils veulent et peuvent le faire exécuter directement sur la carte graphique.
Les ''shaders'' sont classifiés suivant les données qu'ils manipulent : '''''pixel shader''''' pour ceux qui manipulent des pixels, '''''vertex shaders''''' pour ceux qui manipulent des sommets. Les premiers sont utilisés pour implémenter l'éclairage par pixel, les autres pour gérer tout ce qui a trait à la géométrie, pas seulement l'éclairage par sommets.
Direct X 8.0 avait un standard pour les shaders, appelé ''shaders 1.0'', qui correspondait parfaitement à ce dont était capable la Geforce 3. Il standardisait les ''vertex shaders'' de la Geforce 3, mais il a aussi renommé les ''register combiners'' comme étant des ''pixel shaders'' version 1.0. Les ''register combiners'' n'ont pas évolués depuis la Geforce 256, si ce n'est que les programmes sont passés de deux opérations successives à 8, et qu'il y avait possibilité de lire 4 textures en ''multitexturing''. A l'opposé, le processeur de ''vertex shader'' de la Geforce 3 était capable d'exécuter des programmes de 128 opérations consécutives et avait 258 registres différents !
Des ''pixels shaders'' plus évolués sont arrivés avec l'ATI Radeon 8500 et ses dérivés. Elle incorporait la technologie ''SMARTSHADER'' qui remplacait les ''registers combiners'' par un processeur de ''shader'' un peu limité. Un point est que le processeur acceptait de calculer des adresses de texture dans le ''pixel shader''. Avant, les adresses des texels à lire étaient fournis par l'unité de rastérisation et basta. L'avantage est que certains effets graphiques étaient devenus possibles : du ''bump-mapping'' avancé, des textures procédurales, de l'éclairage par pixel anisotrope, du éclairage de Phong réel, etc.
Avec la Radeon 8500, le ''pixel shader'' pouvait calculer des adresses, et lire les texels associés à ces adresses calculées. Les ''pixel shaders'' pouvaient lire 6 textures, faire 8 opérations sur les texels lus, puis lire 6 textures avec les adresses calculées à l'étape précédente, et refaire 8 opérations. Quelque chose de limité, donc, mais déjà plus pratique. Les ''pixel shaders'' de ce type ont été standardisé dans Direct X 8.1, sous le nom de ''pixel shaders 1.4''. Encore une fois, le hardware a forcé l'intégration dans une API 3D.
===Les ''shaders'' de Direct X 9.0 : de vrais ''pixel shaders''===
Avec Direct X 9.0, les ''shaders'' sont devenus de vrais programmes, sans les limitations des ''shaders'' précédents. Les ''pixels shaders'' sont passés à la version 2.0, idem pour les ''vertex shaders''. Concrètement, ils sont maintenant exécutés par un processeur de ''shader'' dédié, aux fonctionnalités bien supérieures à celles des ''registers combiners''. Les ''shaders'' pouvaient exécuter une suite d'opérations arbitraire, dans le sens où elle n'était pas structurée avec tel type d'opération au début, suivie par un accès aux textures, etc. On pouvait mettre n'importe quelle opération dans n'importe quel ordre.
De plus, les ''shaders'' ne sont plus écrit en assembleur comme c'était le cas avant. Ils sont dorénavant écrits dans un langage de haut-niveau, le HLSL pour les shaders Direct X et le GLSL pour les shaders Open Gl. Les ''shaders'' sont ensuite traduit (compilés) en instructions machines compréhensibles par la carte graphique. Au début, ces langages et la carte graphique supportaient uniquement des opérations simples. Mais au fil du temps, les spécifications de ces langages sont devenues de plus en plus riches à chaque version de Direct X ou d'Open Gl, et le matériel en a fait autant.
[[File:Architecture de base d'une carte 3D - 5.png|centre|vignette|upright=1.5|Carte 3D avec pixels et vertex shaders non-unifiés.]]
===L'après Direct X 9.0===
Avant Direct X 10, les processeurs de ''shaders'' ne géraient pas exactement les mêmes opérations pour les processeurs de ''vertex shader'' et de ''pixel shader''. Les processeurs de ''vertex shader'' et de ''pixel shader''étaient séparés. Depuis DirectX 10, ce n'est plus le cas : le jeu d'instructions a été unifié entre les vertex shaders et les pixels shaders, ce qui fait qu'il n'y a plus de distinction entre processeurs de vertex shaders et de pixels shaders, chaque processeur pouvant traiter indifféremment l'un ou l'autre.
[[File:Architecture de base d'une carte 3D - 6.png|centre|vignette|upright=1.5|Architecture de la GeForce 6800.]]
Avec Direct X 10, de nombreux autres ''shaders'' sont apparus. Les plus liés au rendu 3D sont les '''''geometry shader''''' pour ceux qui manipulent des triangles, de ''hull shaders'' et de ''domain shaders'' pour la tesselation. De plus, les cartes graphiques modernes sont capables d’exécuter des programmes informatiques qui n'ont aucun lien avec le rendu 3D, mais sont exécutés par la carte graphique comme le ferait un processeur d'ordinateur normal. De tels ''shaders'' sans lien avec le rendu 3D sont appelés des ''compute shader''.
==Les cartes graphiques d'aujourd'hui==
Avec l'arrivée des shaders, les circuits d'une carte graphique sont divisés en deux catégories : d'un côté les circuits non-programmables et de l'autre les circuits programmables. Pour exécuter les ''shaders'', la carte graphique incorpore des '''processeurs de ''shaders''''', des processeurs similaires aux processeurs des ordinateurs, aux CPU, mais avec quelques petites différences qu'on expliquera dans le prochain chapitre. A côté des processeurs de ''shaders'', il reste quelques circuits non(programmables appelés des circuits fixes. De nos jours, la gestion de la géométrie et des pixels est programmable, mais la rastérisation, le placage de texture, le ''culling'' et l'enregistrement du ''framebuffer'' ne l'est pas. Il n'en a pas toujours été ainsi.
[[File:3D-Pipeline.svg|centre|vignette|upright=3.0|Pipeline 3D : ce qui est programmable et ce qui ne l'est pas dans une carte graphique moderne.]]
===Les GPU modernes sont un mélange de processeurs et de circuits fixes===
Une carte graphique contient donc un mélange de circuits fixes et de processeurs de ''shaders'', qui peut sembler contradictoire. Pourquoi ne pas tout rendre programmable ? Ou au contraire, utiliser seulement des circuits fixes ? La réponse rapide est qu'il s'agit d'un compromis entre flexibilité et performance qui permet d'avoir le meilleur des deux mondes. Mais ce compromis a fortement évolué dans le temps, comme on va le voir plus bas.
Rendre la gestion de la géométrie ou des pixels programmable permet d'implémenter facilement un grand nombre d'effets graphiques sans avoir à les implémenter en hardware. Avant les ''shaders'', seul le hardware récent gérait les dernières fonctionnalités. Les effets graphiques derniers cri n'étaient disponibles que sur les derniers modèles de carte graphique. Avec des ''vertex/pixel shaders'', ce genre de défaut est passé à la trappe. Si un nouvel algorithme de rendu graphique est inventé, il peut être utilisé dès le lendemain sur toutes les cartes graphiques modernes. De plus, implémenter beaucoup d'algorithmes d'éclairage différents est difficile. Le cout en termes de transistors et de complexité était assez important, utiliser des circuits programmable a un cout en hardware plus limité.
Tout cela est à l'exact opposé de ce qu'on a avec les autres circuits, comme les circuits pour la rastérisation ou le placage de texture. Il n'y a pas 36 façons de rastériser une scène 3D et la flexibilité n'est pas un besoin important pour cette opération, alors que les performances sont cruciales. Même chose pour le placage/filtrage de textures. En conséquences, les unités de transformation, de rastérisation et de placage de texture sont toutes implémentées en matériel. Faire ainsi permet de gagner en performance sans que cela ait le moindre impact pour le programmeur.
===Les unités de texture sont intégrées aux processeurs de shaders===
Les unités de textures sont à part des autres circuits fixes, dans le sens où ce sont les seuls à être implémentés dans les processeurs de shaders. Sur les anciennes cartes 3D, les unités de textures étaient des circuits séparés des autres. Mais avec l'arrivée des processeurs de shaders, elles ont été intégrée dans les processeurs de shaders eux-mêmes. Pour comprendre pourquoi, il faut faire un rappel sur ce qu'il y a dans un processeur. Un processeur contient globalement quatre circuits :
* une unité de calcul qui fait des calculs ;
* des registres pour stocker les opérandes et résultats des calculs ;
* une unité de communication avec la mémoire ;
* et un séquenceur, un circuit de contrôle qui commande les autres.
L'unité de communication avec la mémoire sert à lire ou écrire des données, à les transférer de la RAM vers les registres, ou l'inverse. Lire une donnée demande d'envoyer son adresse à la RAM, qui répond en envoyant la donnée lue. Elle est donc toute indiquée pour lire une texture : lire une texture n'est qu'un cas particulier de lecture de données. Les texels à lire sont à une adresse précise, la RAM répond à la lecture avec le texel demandé. Il est donc possible d'utiliser l'unité de communication avec la mémoire comme si c'était une unité de texture.
Cependant, les textures ne sont pas utilisées comme telles de nos jours. Le rendu 3D moderne utilise des techniques dites de filtrage de texture, qui permettent d'améliorer la qualité du rendu des textures. Sans ce filtrage de texture, les textures appliquées naïvement donnent un résultat assez pixelisé et assez moche, pour des raisons assez techniques. Le filtrage élimine ces artefacts, en utilisant une forme d'''antialiasing'' interne aux textures, le fameux filtrage de texture.
Le filtrage de texture peut être réalisé en logiciel ou en matériel. Techniquement, il est possible de le faire dans un ''shader'' : le ''shader'' calcule les adresses des texels à lire, lit plusieurs texels, et effectue ensuite le filtrage. En soi, rien d'impossible. Mais le filtrage de texture est toujours effectué directement en matériel. Les processeurs de shaders incorporent des circuits de filtrage de texture, dans l'unité de communication avec la mémoire. La raison est que le filtrage de texture est une opération très simple à implémenter en hardware, qui demande assez peu de circuits. Le filtrage bilinéaire ou trilinéaire demande juste des circuits d'interpolation et quelques registres, ce qui est trivial. Et la seconde raison est qu'il n'y a pas 36 façons de filtrer des textures : une carte graphique peut implémenter les algorithmes principaux existants en assez peu de circuits.
: Il faut noter que les ROPs peuvent aussi être intégré dans les processeurs de ''shader'', mais c'est assez rare. Les cartes graphiques pour PC ont des ROPs séparés des processeurs de ''shaders''. Par contre, quelques cartes graphiques destinées les smartphones et autres appareils mobiles font exception. Sur celles-ci, les unités de textures et les ROPs sont intégrés dans les processeurs de ''shaders'', aux côtés de l'unité d'accès mémoire LOAD/STORE. Il s'agit de cartes graphiques en rendu à tuiles.
{{NavChapitre | book=Les cartes graphiques
| prev=Les cartes graphiques : architecture de base
| prevText=Les cartes graphiques : architecture de base
| next=Les processeurs de shaders
| nextText=Les processeurs de shaders
}}
{{autocat}}
07oxg8ezjycqq82slljm95jxhtkq71t
744097
744096
2025-06-04T00:20:10Z
Mewtow
31375
/* Les unités de texture sont intégrées aux processeurs de shaders */
744097
wikitext
text/x-wiki
Il est intéressant d'étudier le hardware des cartes graphiques en faisant un petit résumé de leur évolution dans le temps. En effet, leur hardware a fortement évolué dans le temps. Et il serait difficile à comprendre le hardware actuel sans parler du hardware d'antan. En effet, une carte graphique moderne est partiellement programmable. Certains circuits sont totalement programmables, d'autres non. Et pour comprendre pourquoi, il faut étudier comment ces circuits ont évolués.
Le hardware des cartes graphiques a fortement évolué dans le temps, ce qui n'est pas une surprise. Les évolutions de la technologie, avec la miniaturisation des transistors et l'augmentation de leurs performances a permit aux cartes graphiques d'incorporer de plus en plus de circuits avec les années. Avant l'invention des cartes graphiques, toutes les étapes du pipeline graphique étaient réalisées par le processeur : il calculait l'image à afficher, et l’envoyait à une carte d'affichage 2D. Au fil du temps, de nombreux circuits furent ajoutés, afin de déporter un maximum de calculs vers la carte vidéo.
Le rendu 3D moderne est basé sur le placage de texture inverse, avec des coordonnées de texture, une correction de perspective, etc. Mais les anciennes consoles et bornes d'arcade utilisaient le placage de texture direct. Et cela a impacté le hardware des consoles/PCs de l'époque. Avec le placage de texture direct, il était primordial de calculer la géométrie, mais la rasterisation était le fait de VDC améliorés. Aussi, les premières bornes d'arcade 3D et les consoles de 5ème génération disposaient processeurs pour calculer la géométrie et de circuits d'application de textures très particuliers. A l'inverse, les PC utilisaient un rendu inverse, totalement différent. Sur les PC, les premières cartes graphiques avaient un circuit de rastérisation et des unités de textures, mais pas de circuits géométriques.
==Les précurseurs : les cartes graphiques des bornes d'arcade==
[[File:Sega ST-V Dynamite Deka PCB 20100324.jpg|vignette|Sega ST-V Dynamite Deka PCB 20100324]]
L'accélération du rendu 3D sur les bornes d'arcade était déjà bien avancé dès les années 90. Les bornes d'arcade ont toujours été un segment haut de gamme de l'industrie du jeu vidéo, aussi ce n'est pas étonnant. Le prix d'une borne d'arcade dépassait facilement les 10 000 dollars pour les plus chères et une bonne partie du prix était celui du matériel informatique. Le matériel était donc très puissant et débordait de mémoire RAM comparé aux consoles de jeu et aux PC.
La plupart des bornes d'arcade utilisaient du matériel standardisé entre plusieurs bornes. A l'intérieur d'une borne d'arcade se trouve une '''carte de borne d'arcade''' qui est une carte mère avec un ou plusieurs processeurs, de la RAM, une carte graphique, un VDC et pas mal d'autres matériels. La carte est reliée aux périphériques de la borne : joysticks, écran, pédales, le dispositif pour insérer les pièces afin de payer, le système sonore, etc. Le jeu utilisé pour la borne est placé dans une cartouche qui est insérée dans un connecteur spécialisé.
Les cartes de bornes d'arcade étaient généralement assez complexes, elles avaient une grande taille et avaient plus de composants que les cartes mères de PC. Chaque carte contenait un grand nombre de chips pour la mémoire RAM et ROM, et il n'était pas rare d'avoir plusieurs processeurs sur une même carte. Et il n'était pas rare d'avoir trois à quatre cartes superposées dans une seule borne. Pour ceux qui veulent en savoir plus, Fabien Sanglard a publié gratuitement un livre sur le fonctionnement des cartes d'arcade CPS System, disponible via ce lien : [https://fabiensanglard.net/b/cpsb.pdf The book of CP System].
Les premières cartes graphiques des bornes d'arcade étaient des cartes graphiques 2D auxquelles on avait ajouté quelques fonctionnalités. Les sprites pouvaient être tournés, agrandit/réduits, ou déformés pour simuler de la perspective et faire de la fausse 3D. Par la suite, le vrai rendu 3D est apparu sur les bornes d'arcade, avec des GPUs totalement programmables.
Dès 1988, la carte d'arcade Namco System 21 et Sega Model 1 géraient les calculs géométriques. Les deux cartes n'utilisaient pas de circuit géométrique fixe, mais l'émulaient avec un processeur programmé avec un programme informatique qui implémentait le T&L en logiciel. Elles utilisaient plusieurs DSP pour ce faire.
Quelques années plus tard, les cartes graphiques se sont mises à supporter un éclairage de Gouraud et du placage de texture. Le Namco System 22 et la Sega model 2 supportaient des textures 2D et comme le filtrage de texture (bilinéaire et trilinéaire), le mip-mapping, et quelques autres. Par la suite, elles ont réutilisé le hardware des PC et autres consoles de jeux.
==La 3D sur les consoles de quatrième génération==
Les consoles avant la quatrième génération de console étaient des consoles purement 2D, sans circuits d'accélération 3D. Leur carte graphique était un simple VDC 2D, plus ou moins performant selon la console. Pourtant, les consoles de quatrième génération ont connus quelques jeux en 3D. Par exemple, les jeux Star Fox sur SNES. Fait important, il s'agissait de vrais jeux en 3D qui tournaient sur des consoles qui ne géraient pas la 3D. La raison à cela est que la 3D était calculée par un GPU placé dans les cartouches du jeu !
Par exemple, les cartouches de Starfox et de Super Mario 2 contenait un coprocesseur Super FX, qui gérait des calculs de rendu 2D/3D. Un autre exemple est celui du co-processeur Cx4, cousin du Super FX, qui était spécialisé dans les calculs trigonométriques et diverses opérations utiles pour le rendu 2D/3D. En tout, il y a environ 16 coprocesseurs pour la SNES et on en trouve facilement la liste sur le net.
La console était conçue pour, des pins sur les ports cartouches étaient prévues pour des fonctionnalités de cartouche annexes, dont ces coprocesseurs. Ces pins connectaient le coprocesseur au bus des entrées-sorties. Les coprocesseurs des cartouches de NES avaient souvent de la mémoire rien que pour eux, qui était intégrée dans la cartouche.
==L'arrivée des consoles de cinquième génération==
Par la suite, les consoles de jeu se sont mises à intégrer des cartes graphiques 3D. Les premières consoles de jeu capables de rendu 3D sont les consoles dites de 5ème génération. Il y a diverses manières de classer les consoles en générations, la plus commune place la 3D à la 5ème génération, mais détailler ces controverses quant à ce classement nous amènerait trop loin. Les cartes graphiques des consoles de jeu utilisaient le rendu inverse, avec quelques exceptions qui utilisaient le rendu inverse.
===La Nintendo 64 : un GPU avancé===
La Nintendo 64 avait le GPU le plus complexe comparé aux autres consoles, et dépassait même les cartes graphiques des PC. Son GPU était très novateur pour une console sortie en 1996. Il incorporait une unité pour les calculs géométriques, un circuit pour la rasterisation, une unité pour les textures et un ROP final pour les calculs de transparence/brouillard/anti-aliasing, ainsi qu'un circuit pour gérer la profondeur des pixels. En somme, tout le pipeline graphique était implémenté dans le GPU de la Nintendo 64, chose très en avance sur son temps, même comparé au PC !
Le GPU est construit autour d'un processeur dédié aux calculs géométriques, le ''Reality Signal Processor'' (RSP), autour duquel on a ajouté des circuits pour le reste du pipeline graphique. L'unité de calcul géométrique est un processeur MIPS R4000, un processeur assez courant à l'époque, mais auquel on avait retiré quelques fonctionnalités inutiles pour le rendu 3D. Il était couplé à 4 KB de mémoire vidéo, ainsi qu'à 4 KB de mémoire ROM. Le reste du GPU était réalisé avec des circuits fixes. La Nintendo 64 utilisait déjà un mélange de circuits programmables et fixes.
Un point intéressant est que le programme exécuté par le RSP pouvait être programmé ! Le RSP gérait déjà des espèces de proto-shaders, qui étaient appelés des ''[https://ultra64.ca/files/documentation/online-manuals/functions_reference_manual_2.0i/ucode/microcode.html micro-codes]'' dans la documentation de l'époque. La ROM associée au RSP mémorise cinq à sept programmes différents, des microcodes de base, aux fonctionnalités différentes. Ils géraient le rendu 3D de manière différente et avec une gestion des ressources différentes. Très peu de studios de jeu vidéo ont développé leur propre microcodes N64, car la documentation était mal faite, que Nintendo ne fournissait pas de support officiel pour cela, que les outils de développement ne permettaient pas de faire cela proprement et efficacement.
===La Playstation 1===
Sur la Playstation 1 le calcul de la géométrie était réalisé par le processeur, la carte graphique gérait tout le reste. Et la carte graphique était un circuit fixe spécialisé dans la rasterisation et le placage de textures. Elle utilisait, comme la Nintendo 64, le placage de texture inverse, qui est apparu ensuite sur les cartes graphiques.
===La 3DO et la Sega Saturn===
La Sega Saturn et la 3DO étaient les deux seules consoles à utiliser le rendu direct. La géométrie était calculée sur le processeur, même si les consoles utilisaient parfois un CPU dédié au calcul de la géométrie. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures.
La Sega Saturn incorpore trois processeurs et deux GPU. Les deux GPUs sont nommés le VDP1 et le VDP2. Le VDP1 s'occupe des textures et des sprites, le VDP2 s'occupe uniquement de l'arrière-plan et incorpore un VDC tout ce qu'il y a de plus simple. Ils ne gèrent pas du tout la géométrie, qui est calculée par les trois processeurs.
Le troisième processeur, la Saturn Control Unit, est un processeur de type DSP, à savoir un processeur spécialisé dans le traitement de signal. Il est utilisé presque exclusivement pour accélérer les calculs géométriques. Il avait sa propre mémoire RAM dédiée, 32 KB de SRAM, soit une mémoire locale très rapide. Les transferts entre cette RAM et le reste de l'ordinateur était géré par un contrôleur DMA intégré dans le DSP. En somme, il s'agit d'une sorte de processeur spécialisé dans la géométrie, une sorte d'unité géométrique programmable. Mais la géométrie n'était pas forcément calculée que sur ce DSP, mais pouvait être prise en charge par les 3 CPU.
==L'historique des cartes graphiques des PC, avant l'arrivée de Direct X et Open Gl==
Sur PC, l'évolution des cartes graphiques a eu du retard par rapport aux consoles. Les PC sont en effet des machines multi-usage, pour lesquelles le jeu vidéo était un cas d'utilisation parmi tant d'autres. Et les consoles étaient la plateforme principale pour jouer à des jeux vidéo, le jeu vidéo PC étant plus marginal. Mais cela ne veut pas dire que le jeu PC n'existait pas, loin de là !
Un problème pour les jeux PC était que l'écosystème des PC était aussi fragmenté en plusieurs machines différentes : machines Apple 1 et 2, ordinateurs Commdore et Amiga, IBM PC et dérivés, etc. Aussi, programmer des jeux PC n'était pas mince affaire, car les problèmes de compatibilité étaient légion. C'est seulement quand la plateforme x86 des IBM PC s'est démocratisée que l'informatique grand public s'est standardisée, réduisant fortement les problèmes de compatibilité. Mais cela n'a pas suffit, il a aussi fallu que les API 3D naissent.
Les API 3D comme Direct X et Open GL sont absolument cruciales pour garantir la compatibilité entre plusieurs ordinateurs aux cartes graphiques différentes. Aussi, l'évolution des cartes graphiques pour PC s'est faite main dans la main avec l'évolution des API 3D. Il a fallu que Direct X et Open GL progressent suffisamment pour que les problèmes de compatibilité soient partiellement résolus. Les fonctionnalités des cartes graphiques ont évolué dans le temps, en suivant les évolutions des API 3D. Du moins dans les grandes lignes, car il est arrivé plusieurs fois que des fonctionnalités naissent sur les cartes graphiques, pour que les fabricants forcent la main de Microsoft ou d'Open GL pour les intégrer de force dans les API 3D. Passons.
===L'introduction des premiers jeux 3D : Quake et les drivers miniGL===
L'histoire de la 3D sur PC commence avec la sortie du jeu Quake, d'IdSoftware. Celui-ci pouvait fonctionner en rendu logiciel, mais le programmeur responsable du moteur 3D (le fameux John Carmack) ajouta une version OpenGL du jeu. Le fait que le jeu était programmé sur une station de travail compatible avec OpenGL faisait que ce choix n'était si stupide, même si aucune carte accélératrice de l'époque ne supportait OpenGL. C'était là un choix qui se révéla visionnaire. En théorie, le rendu par OpenGL aurait dû se faire intégralement en logiciel, sauf sur quelques rares stations de travail adaptées. Mais les premières cartes graphiques étaient déjà dans les starting blocks.
La toute première carte 3D pour PC est la Rendition Vérité V1000, sortie en Septembre 1995, soit quelques mois avant l'arrivée de la Nintendo 64. La Rendition Vérité V1000 était purement programmable, contrairement aux autres cartes graphiques de l'époque. Elle contenait un processeur MIPS cadencé à 25 MHz, 4 mébioctets de RAM, une ROM pour le BIOS, et un RAMDAC, rien de plus. C'était un vrai ordinateur complètement programmable de bout en bout. Les programmeurs ne pouvaient cependant pas utiliser cette programmabilité avec des ''shaders'', mais elle permettait à Rendition d'implémenter n'importe quelle API 3D, que ce soit OpenGL, DirectX ou même sa son API propriétaire.
La Rendition Vérité avait de bonnes performances pour ce qui est de la géométrie, mais pas pour le reste. Autant les calculs géométriques sont assez rapides quand on les exécute sur un CPU, autant réaliser la rastérisation et le placage de texture en logiciel n'est pas efficace, pareil pour les opérations de fin de pipeline comme l'antialiasing. Le manque d'unités fixes très rapides pour la rastérisation, le placage de texture ou les opérations de fin de pipeline était clairement un gros défaut. Les autres cartes graphiques avaient implémenté l'exact inverse : de bonnes performances pour le placage de textures et la rastérization, mais les calculs géométriques étaient réalisés par le CPU. Au final, la carte graphique qui s'en sortait le mieux était la Nintendo 64 qui avait un CPU dédié pour les calculs géométriques et des circuits fixes pour le reste...
Les autres cartes graphiques étaient totalement non-programmables et ne contenant que des circuits fixes, regroupe les Voodoo de 3dfx, les Riva TNT de NVIDIA, les Rage/3D d'ATI, la Virge/3D de S3, et la Matrox Mystique. Elles contenaient des circuits pour gérer les textures, mais aussi une étape d'enregistrement des pixels en mémoire. Elle était gérait le ''z-buffer'' en mémoire vidéo, mais aussi quelques effets graphiques comme les effets de brouillard. L'unité d'enregistrement des pixels en mémoire s'appelle le ROP pour ''Raster Operation Pipeline''.
[[File:Architecture de base d'une carte 3D - 2.png|centre|vignette|upright=1.5|Carte 3D sans rasterization matérielle.]]
Les cartes suivantes ajoutèrent une gestion des étapes de ''rasterization'' directement en matériel. Les cartes ATI rage 2, les Invention de chez Rendition, et d'autres cartes graphiques supportaient la rasterisation en hardware.
[[File:Architecture de base d'une carte 3D - 3.png|centre|vignette|upright=1.5|Carte 3D avec gestion de la géométrie.]]
Pour exploiter les unités de texture et le circuit de rastérisation, OpenGL et Direct 3D étaient partiellement implémentées en logiciel, car les cartes graphiques ne supportaient pas toutes les fonctionnalités de l'API. C'était l'époque du miniGL, des implémentations partielles d'OpenGL, fournies par les fabricants de cartes 3D, implémentées dans les pilotes de périphériques de ces dernières. Les fonctionnalités d'OpenGL implémentées dans ces pilotes étaient presque toutes exécutées en matériel, par la carte graphique. Avec l'évolution du matériel, les pilotes de périphériques devinrent de plus en plus complets, au point de devenir des implémentations totales d'OpenGL.
Mais au-delà d'OpenGL, chaque fabricant de carte graphique avait sa propre API propriétaire, qui était gérée par leurs pilotes de périphériques (''drivers''). Par exemple, les premières cartes graphiques de 3dfx interactive, les fameuses voodoo, disposaient de leur propre API graphique, l'API Glide. Elle facilitait la gestion de la géométrie et des textures, ce qui collait bien avec l'architecture de ces cartes 3D. Mais ces API propriétaires tombèrent rapidement en désuétude avec l'évolution de DirectX et d'OpenGL.
Direct X était une API dans l'ombre d'Open GL. La première version de Direct X qui supportait la 3D était DirectX 2.0 (juin 2, 1996), suivie rapidement par DirectX 3.0 (septembre 1996). Elles dataient d'avant le jeu Quake, et elles étaient très éloignées du hardware des premières cartes graphiques. Elles utilisaient un système d'''execute buffer'' pour communiquer avec la carte graphique, Microsoft espérait que le matériel 3D implémenterait ce genre de système. Ce qui ne fu pas le cas.
Direct X 4.0 a été abandonné en cours de développement pour laisser à une version 5.0 assez semblable à la 2.0/3.0. Le mode de rendu laissait de côté les ''execute buffer'' pour coller un peu plus au hardware de l'époque. Mais rien de vraiment probant comparé à Open GL. Même Windows utilisait Open GL au lieu de Direct X maison... C'est avec Direct X 6.0 que Direct X est entré dans la cours des grands. Il gérait la plupart des technologies supportées par les cartes graphiques de l'époque.
===Le ''multi-texturing'' de l'époque Direct X 6.0 : combiner plusieurs textures===
Une technologie très importante standardisée par Dirext X 6 est la technique du '''''multi-texturing'''''. Avec ce qu'on a dit dans le chapitre précédent, vous pensez sans doute qu'il n'y a qu'une seule texture par objet, qui est plaquée sur sa surface. Mais divers effet graphiques demandent d'ajouter des textures par dessus d'autres textures. En général, elles servent pour ajouter des détails, du relief, sur une surface pré-existante.
Un exemple intéressant vient des jeux de tir : ajouter des impacts de balles sur les murs. Pour cela, on plaque une texture d'impact de balle sur le mur, à la position du tir. Il s'agit là d'un exemple de '''''decals''''', des petites textures ajoutées sur les murs ou le sol, afin de simuler de la poussière, des impacts de balle, des craquelures, des fissures, des trous, etc. Les textures en question sont de petite taille et se superposent à une texture existante, plus grande. Rendre des ''decals'' demande de pouvoir superposer deux textures.
Direct X 6.0 supportait l'application de plusieurs textures directement dans le matériel. La carte graphique devait être capable d'accéder à deux textures en même temps, ou du moins faire semblant que. Pour cela, elle doublaient les unités de texture et adaptaient les connexions entre unités de texture et mémoire vidéo. La mémoire vidéo devait être capable de gérer plusieurs accès mémoire en même temps et devait alors avoir un débit binaire élevé.
[[File:Multitexturing.png|centre|vignette|upright=2|Multitexturing]]
La carte graphique devait aussi gérer de quoi combiner deux textures entre elles. Par exemple, pour revenir sur l'exemple d'une texture d'impact de balle, il faut que la texture d'impact recouvre totalement la texture du mur. Dans ce cas, la combinaison est simple : la première texture remplace l'ancienne, là où elle est appliquée. Mais les cartes graphiques ont ajouté d'autres combinaisons possibles, par exemple additionner les deux textures entre elle, faire une moyenne des texels, etc.
Les opérations pour combiner les textures était le fait de circuits appelés des '''''combiners'''''. Concrètement, les ''combiners'' sont de simples unités de calcul. Les ''conbiners'' ont beaucoup évolués dans le temps, mais les premières implémentation se limitaient à quelques opérations simples : addition, multiplication, superposition, interpolation. L'opération effectuer était envoyée au ''conbiner'' sur une entrée dédiée.
[[File:Multitexturing avec combiners.png|centre|vignette|upright=2|Multitexturing avec combiners]]
S'il y avait eu un seul ''conbiner'', le circuit de ''multitexturing'' aurait été simplement configurable. Mais dans la réalité, les premières cartes utilisant du ''multi-texturing'' utilisaient plusieurs ''combiners'' placés les uns à la suite des autres. L'implémentation des ''combiners'' retenue par Open Gl, et par le hardware des cartes graphiques, était la suivante. Les ''combiners'' étaient placés en série, l'un à la suite de l'autre, chacun combinant le résultat de l'étage précédent avec une texture. Le premier ''combiner'' gérait l'éclairage par sommet, afin de conserver un minimum de rétrocompatibilité.
[[File:Texture combiners Open GL.png|centre|vignette|upright=2|Texture combiners Open GL]]
Voici les opérations supportées par les ''combiners'' d'Open GL. Ils prennent en entrée le résultat de l'étage précédent et le combinent avec une texture lue depuis l'unité de texture.
{|class="wikitable"
|+ Opérations supportées par les ''combiners'' d'Open GL
|-
! Replace
| colspan="2" | Pixel provenant de l'unité de texture
|-
! Addition
| colspan="2" | Additionne l'entrée au texel lu.
|-
! Modulate
| colspan="2" | Multiplie l'entrée avec le texel lu
|-
! Mélange (''blending'')
| Moyenne pondérée des deux entrées, pondérée par la composante de transparence || La couleur de transparence du texel lu et de l'entrée sont multipliées.
|-
! Decals
| Moyenne pondérée des deux entrées, pondérée par la composante de transparence. || La transparence du résultat est celle de l'entrée.
|}
Il faut noter qu'un dernier étage de ''combiners'' s'occupait d'ajouter la couleur spéculaire et les effets de brouillards. Il était à part des autres et n'était pas configurable, c'était un étage fixe, qui était toujours présent, peu importe le nombre de textures utilisé. Il était parfois appelé le '''''combiner'' final''', terme que nous réutiliserons par la suite.
Mine de rien, cela a rendu les cartes graphiques partiellement programmables. Le fait qu'il y ait des opérations enchainées à la suite, opérations qu'on peut choisir librement, suffit à créer une sorte de mini-programme qui décide comment mélanger plusieurs textures. Mais il y avait une limitation de taille : le fait que les données soient transmises d'un étage à l'autre, sans détours possibles. Par exemple, le troisième étage ne pouvait avoir comme seule opérande le résultat du second étage, mais ne pouvait pas utiliser celui du premier étage. Il n'y avait pas de registres pour stocker ce qui sortait de la rastérisation, ni pour mémoriser temporairement les texels lus.
===Le ''Transform & Lighting'' matériel de Direct X 7.0===
[[File:Architecture de base d'une carte 3D - 4.png|vignette|upright=1.5|Carte 3D avec gestion de la géométrie.]]
La première carte graphique pour PC capable de gérer la géométrie en hardware fût la Geforce 256, la toute première Geforce. Son unité de gestion de la géométrie n'est autre que la bien connue '''unité T&L''' (''Transform And Lighting''). Elle implémentait des algorithmes d'éclairage de la scène 3D assez simples, comme un éclairage de Gouraud, qui étaient directement câblés dans ses circuits. Mais contrairement à la Nintendo 64 et aux bornes d'arcade, elle implémentait le tout, non pas avec un processeur classique, mais avec des circuits fixes.
Avec Direct X 7.0 et Open GL 1.0, l'éclairage était en théorie limité à de l'éclairage par sommet, l'éclairage par pixel n'était pas implémentable en hardware. Les cartes graphiques ont tenté d'implémenter l'éclairage par pixel, mais cela n'est pas allé au-delà du support de quelques techniques de ''bump-mapping'' très limitées. Par exemple, Direct X 6.0 implémentait une forme limitée de ''bump-mapping'', guère plus.
Un autre problème est que l'éclairage peut s'implémenter de plusieurs manières différentes, aux résultats visuels différents. Les unités de T&L étaient souvent en retard sur les algorithmes logiciels. Les programmeurs avaient le choix entre programmer les algorithmes d’éclairage qu'ils voulaient et les exécuter en logiciel ou utiliser ceux de l'unité de T&L, et choisissaient souvent la première option. Par exemple, Quake 3 Arena et Unreal Tournament n'utilisaient pas les capacités d'éclairage géométrique et préféraient utiliser leurs calculs d'éclairage logiciel fait maison.
Cependant, le hardware dépassait les capacités des API et avait déjà commencé à ajouter des capacités de programmation liées au ''multi-texturing''. Les cartes graphiques de l'époque, surtout chez NVIDIA, implémentaient un système de '''''register combiners''''', une forme améliorée de ''texture combiners'', qui permettait de faire une forme limitée d'éclairage par pixel, notamment du vrai ''bump-mampping'', voire du ''normal-mapping''. Mais ce n'était pas totalement supporté par les API 3D de l'époque.
Les ''registers combiners'' sont des ''texture combiners'' mais dans lesquels ont aurait retiré la stricte organisation en série. Il y a toujours plusieurs étages à la suite, qui peuvent exécuter chacun une opération, mais tous les étages ont maintenant accès à toutes les textures lues et à tout ce qui sort de la rastérisation, pas seulement au résultat de l'étape précédente. Pour cela, on ajoute des registres pour mémoriser ce qui sort des unités de texture, et pour ce qui sort de la rastérisation. De plus, on ajoute des registres temporaires pour mémoriser les résultats de chaque ''combiner'', de chaque étage.
Il faut cependant signaler qu'il existe un ''combiner'' final, séparé des étages qui effectuent des opérations proprement dits. Il s'agit de l'étage qui applique la couleur spéculaire et les effets de brouillards. Il ne peut être utilisé qu'à la toute fin du traitement, en tant que dernier étage, on ne peut pas mettre d'opérations après lui. Sa sortie est directement connectée aux ROPs, pas à des registres. Il faut donc faire la distinction entre les '''''combiners'' généraux''' qui effectuent une opération et mémorisent le résultat dans des registres, et le ''combiner'' final qui envoie le résultat aux ROPs.
L'implémentation des ''register combiners'' utilisait un processeur spécialisés dans les traitements sur des pixels, une sorte de proto-processeur de ''shader''. Le processeur supportait des opérations assez complexes : multiplication, produit scalaire, additions. Il s'agissait d'un processeur de type VLIW, qui sera décrit dans quelques chapitres. Mais ce processeur avait des programmes très courts. Les premières cartes NVIDIA, comme les cartes TNT pouvaient exécuter deux opérations à la suite, suivie par l'application de la couleurs spéculaire et du brouillard. En somme, elles étaient limitées à un ''shader'' à deux/trois opérations, mais c'était un début. Le nombre d'opérations consécutives est rapidement passé à 8 sur la Geforce 3.
===L'arrivée des ''shaders'' avec Direct X 8.0===
[[File:Architecture de la Geforce 3.png|vignette|upright=1.5|Architecture de la Geforce 3]]
Les ''register combiners'' était un premier pas vers un éclairage programmable. Paradoxalement, l'évolution suivante s'est faite non pas dans l'unité de rastérisation/texture, mais dans l'unité de traitement de la géométrie. La Geforce 3 a remplacé l'unité de T&L par un processeur capable d'exécuter des programmes. Les programmes en question complétaient l'unité de T&L, afin de pouvoir rajouter des techniques d'éclairage plus complexes. Le tout a permis aussi d'ajouter des animations, des effets de fourrures, des ombres par ''shadow volume'', des systèmes de particule évolués, et bien d'autres.
À partir de la Geforce 3 de Nvidia, les cartes graphiques sont devenues capables d'exécuter des programmes appelés '''''shaders'''''. Le terme ''shader'' vient de ''shading'' : ombrage en anglais. Grace aux ''shaders'', l'éclairage est devenu programmable, il n'est plus géré par des unités d'éclairage fixes mais été laissé à la créativité des programmeurs. Les programmeurs ne sont plus vraiment limités par les algorithmes d'éclairage implémentés dans les cartes graphiques, mais peuvent implémenter les algorithmes d'éclairage qu'ils veulent et peuvent le faire exécuter directement sur la carte graphique.
Les ''shaders'' sont classifiés suivant les données qu'ils manipulent : '''''pixel shader''''' pour ceux qui manipulent des pixels, '''''vertex shaders''''' pour ceux qui manipulent des sommets. Les premiers sont utilisés pour implémenter l'éclairage par pixel, les autres pour gérer tout ce qui a trait à la géométrie, pas seulement l'éclairage par sommets.
Direct X 8.0 avait un standard pour les shaders, appelé ''shaders 1.0'', qui correspondait parfaitement à ce dont était capable la Geforce 3. Il standardisait les ''vertex shaders'' de la Geforce 3, mais il a aussi renommé les ''register combiners'' comme étant des ''pixel shaders'' version 1.0. Les ''register combiners'' n'ont pas évolués depuis la Geforce 256, si ce n'est que les programmes sont passés de deux opérations successives à 8, et qu'il y avait possibilité de lire 4 textures en ''multitexturing''. A l'opposé, le processeur de ''vertex shader'' de la Geforce 3 était capable d'exécuter des programmes de 128 opérations consécutives et avait 258 registres différents !
Des ''pixels shaders'' plus évolués sont arrivés avec l'ATI Radeon 8500 et ses dérivés. Elle incorporait la technologie ''SMARTSHADER'' qui remplacait les ''registers combiners'' par un processeur de ''shader'' un peu limité. Un point est que le processeur acceptait de calculer des adresses de texture dans le ''pixel shader''. Avant, les adresses des texels à lire étaient fournis par l'unité de rastérisation et basta. L'avantage est que certains effets graphiques étaient devenus possibles : du ''bump-mapping'' avancé, des textures procédurales, de l'éclairage par pixel anisotrope, du éclairage de Phong réel, etc.
Avec la Radeon 8500, le ''pixel shader'' pouvait calculer des adresses, et lire les texels associés à ces adresses calculées. Les ''pixel shaders'' pouvaient lire 6 textures, faire 8 opérations sur les texels lus, puis lire 6 textures avec les adresses calculées à l'étape précédente, et refaire 8 opérations. Quelque chose de limité, donc, mais déjà plus pratique. Les ''pixel shaders'' de ce type ont été standardisé dans Direct X 8.1, sous le nom de ''pixel shaders 1.4''. Encore une fois, le hardware a forcé l'intégration dans une API 3D.
===Les ''shaders'' de Direct X 9.0 : de vrais ''pixel shaders''===
Avec Direct X 9.0, les ''shaders'' sont devenus de vrais programmes, sans les limitations des ''shaders'' précédents. Les ''pixels shaders'' sont passés à la version 2.0, idem pour les ''vertex shaders''. Concrètement, ils sont maintenant exécutés par un processeur de ''shader'' dédié, aux fonctionnalités bien supérieures à celles des ''registers combiners''. Les ''shaders'' pouvaient exécuter une suite d'opérations arbitraire, dans le sens où elle n'était pas structurée avec tel type d'opération au début, suivie par un accès aux textures, etc. On pouvait mettre n'importe quelle opération dans n'importe quel ordre.
De plus, les ''shaders'' ne sont plus écrit en assembleur comme c'était le cas avant. Ils sont dorénavant écrits dans un langage de haut-niveau, le HLSL pour les shaders Direct X et le GLSL pour les shaders Open Gl. Les ''shaders'' sont ensuite traduit (compilés) en instructions machines compréhensibles par la carte graphique. Au début, ces langages et la carte graphique supportaient uniquement des opérations simples. Mais au fil du temps, les spécifications de ces langages sont devenues de plus en plus riches à chaque version de Direct X ou d'Open Gl, et le matériel en a fait autant.
[[File:Architecture de base d'une carte 3D - 5.png|centre|vignette|upright=1.5|Carte 3D avec pixels et vertex shaders non-unifiés.]]
===L'après Direct X 9.0===
Avant Direct X 10, les processeurs de ''shaders'' ne géraient pas exactement les mêmes opérations pour les processeurs de ''vertex shader'' et de ''pixel shader''. Les processeurs de ''vertex shader'' et de ''pixel shader''étaient séparés. Depuis DirectX 10, ce n'est plus le cas : le jeu d'instructions a été unifié entre les vertex shaders et les pixels shaders, ce qui fait qu'il n'y a plus de distinction entre processeurs de vertex shaders et de pixels shaders, chaque processeur pouvant traiter indifféremment l'un ou l'autre.
[[File:Architecture de base d'une carte 3D - 6.png|centre|vignette|upright=1.5|Architecture de la GeForce 6800.]]
Avec Direct X 10, de nombreux autres ''shaders'' sont apparus. Les plus liés au rendu 3D sont les '''''geometry shader''''' pour ceux qui manipulent des triangles, de ''hull shaders'' et de ''domain shaders'' pour la tesselation. De plus, les cartes graphiques modernes sont capables d’exécuter des programmes informatiques qui n'ont aucun lien avec le rendu 3D, mais sont exécutés par la carte graphique comme le ferait un processeur d'ordinateur normal. De tels ''shaders'' sans lien avec le rendu 3D sont appelés des ''compute shader''.
==Les cartes graphiques d'aujourd'hui==
Avec l'arrivée des shaders, les circuits d'une carte graphique sont divisés en deux catégories : d'un côté les circuits non-programmables et de l'autre les circuits programmables. Pour exécuter les ''shaders'', la carte graphique incorpore des '''processeurs de ''shaders''''', des processeurs similaires aux processeurs des ordinateurs, aux CPU, mais avec quelques petites différences qu'on expliquera dans le prochain chapitre. A côté des processeurs de ''shaders'', il reste quelques circuits non(programmables appelés des circuits fixes. De nos jours, la gestion de la géométrie et des pixels est programmable, mais la rastérisation, le placage de texture, le ''culling'' et l'enregistrement du ''framebuffer'' ne l'est pas. Il n'en a pas toujours été ainsi.
[[File:3D-Pipeline.svg|centre|vignette|upright=3.0|Pipeline 3D : ce qui est programmable et ce qui ne l'est pas dans une carte graphique moderne.]]
===Les GPU modernes sont un mélange de processeurs et de circuits fixes===
Une carte graphique contient donc un mélange de circuits fixes et de processeurs de ''shaders'', qui peut sembler contradictoire. Pourquoi ne pas tout rendre programmable ? Ou au contraire, utiliser seulement des circuits fixes ? La réponse rapide est qu'il s'agit d'un compromis entre flexibilité et performance qui permet d'avoir le meilleur des deux mondes. Mais ce compromis a fortement évolué dans le temps, comme on va le voir plus bas.
Rendre la gestion de la géométrie ou des pixels programmable permet d'implémenter facilement un grand nombre d'effets graphiques sans avoir à les implémenter en hardware. Avant les ''shaders'', seul le hardware récent gérait les dernières fonctionnalités. Les effets graphiques derniers cri n'étaient disponibles que sur les derniers modèles de carte graphique. Avec des ''vertex/pixel shaders'', ce genre de défaut est passé à la trappe. Si un nouvel algorithme de rendu graphique est inventé, il peut être utilisé dès le lendemain sur toutes les cartes graphiques modernes. De plus, implémenter beaucoup d'algorithmes d'éclairage différents est difficile. Le cout en termes de transistors et de complexité était assez important, utiliser des circuits programmable a un cout en hardware plus limité.
Tout cela est à l'exact opposé de ce qu'on a avec les autres circuits, comme les circuits pour la rastérisation ou le placage de texture. Il n'y a pas 36 façons de rastériser une scène 3D et la flexibilité n'est pas un besoin important pour cette opération, alors que les performances sont cruciales. Même chose pour le placage/filtrage de textures. En conséquences, les unités de transformation, de rastérisation et de placage de texture sont toutes implémentées en matériel. Faire ainsi permet de gagner en performance sans que cela ait le moindre impact pour le programmeur.
===Les unités de texture sont intégrées aux processeurs de shaders===
Les unités de textures sont à part des autres circuits fixes, dans le sens où ce sont les seuls à être implémentés dans les processeurs de shaders. Sur les anciennes cartes 3D, les unités de textures étaient des circuits séparés des autres. Mais avec l'arrivée des processeurs de shaders, elles ont été intégrée dans les processeurs de shaders eux-mêmes. Pour comprendre pourquoi, il faut faire un rappel sur ce qu'il y a dans un processeur. Un processeur contient globalement quatre circuits :
* une unité de calcul qui fait des calculs ;
* des registres pour stocker les opérandes et résultats des calculs ;
* une unité de communication avec la mémoire ;
* et un séquenceur, un circuit de contrôle qui commande les autres.
L'unité de communication avec la mémoire sert à lire ou écrire des données, à les transférer de la RAM vers les registres, ou l'inverse. Lire une donnée demande d'envoyer son adresse à la RAM, qui répond en envoyant la donnée lue. Elle est donc toute indiquée pour lire une texture : lire une texture n'est qu'un cas particulier de lecture de données. Les texels à lire sont à une adresse précise, la RAM répond à la lecture avec le texel demandé. Il est donc possible d'utiliser l'unité de communication avec la mémoire comme si c'était une unité de texture.
Cependant, les textures ne sont pas utilisées comme telles de nos jours. Le rendu 3D moderne utilise des techniques dites de filtrage de texture, qui permettent d'améliorer la qualité du rendu des textures. Sans ce filtrage de texture, les textures appliquées naïvement donnent un résultat assez pixelisé et assez moche, pour des raisons assez techniques. Le filtrage élimine ces artefacts, en utilisant une forme d'''antialiasing'' interne aux textures, le fameux filtrage de texture.
Le filtrage de texture peut être réalisé en logiciel ou en matériel. Techniquement, il est possible de le faire dans un ''shader'' : le ''shader'' calcule les adresses des texels à lire, lit plusieurs texels, et effectue ensuite le filtrage. En soi, rien d'impossible. Mais le filtrage de texture est toujours effectué directement en matériel. Les processeurs de shaders incorporent des circuits de filtrage de texture, dans l'unité de texture.
Pour simplifier l'implémentation, les processeurs de ''shader'' modernes disposent d'une unité d'accès mémoire séparée de l'unité de texture. L'unité d'accès mémoire normale s'occupe des accès mémoire hors-textures, alors que l'unité mémoire s'occupe de lire les textures. L'unité de texture contient de quoi faire du filtrage de texture, mais aussi faire des calculs d'adresse spécialisées, intrinsèquement liés au format des textures, qu'on détaillera dans le chapitre sur les textures. En comparaison, les unités d'accès mémoire effectuent des calculs d'adresse plus basiques. Un dernier avantage est que l'unité de texture est reliée au cache de texture, alors que l'unité d'accès mémoire est relié au cache L1/L2.
La raison est que le filtrage de texture est une opération très simple à implémenter en hardware, qui demande assez peu de circuits. Le filtrage bilinéaire ou trilinéaire demande juste des circuits d'interpolation et quelques registres, ce qui est trivial. Et la seconde raison est qu'il n'y a pas 36 façons de filtrer des textures : une carte graphique peut implémenter les algorithmes principaux existants en assez peu de circuits.
: Il faut noter que les ROPs peuvent aussi être intégré dans les processeurs de ''shader'', mais c'est assez rare. Les cartes graphiques pour PC ont des ROPs séparés des processeurs de ''shaders''. Par contre, quelques cartes graphiques destinées les smartphones et autres appareils mobiles font exception. Sur celles-ci, les unités de textures et les ROPs sont intégrés dans les processeurs de ''shaders'', aux côtés de l'unité d'accès mémoire LOAD/STORE. Il s'agit de cartes graphiques en rendu à tuiles.
{{NavChapitre | book=Les cartes graphiques
| prev=Les cartes graphiques : architecture de base
| prevText=Les cartes graphiques : architecture de base
| next=Les processeurs de shaders
| nextText=Les processeurs de shaders
}}
{{autocat}}
a17i0s6rz2k3npkd97f0i32coxa71yv
Les cartes graphiques/Le processeur de commandes
0
69571
744033
744011
2025-06-03T13:43:54Z
Mewtow
31375
/* Le tampon de commandes */
744033
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le NV1 contenait dans le détail : un controleur DMA avec une IO-MMU intégrée, la FIFO de commande, le processeur de commande, un pipeline graphique, un pipeline sonore, un contrôleur de manette. L'ensemble était relié par un bus interne à la carte graphique. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète et l’exécute, et recommence ainsi de suite. Le processeur de commande est chargé de piloter les circuits de la carte graphique. Un point important est qu'il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. En soi, le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Le processeur de commandes récupère les commandes dans le tampon de commande, en mémoire RAM, pour les recopier dans la mémoire vidéo et/ou une mémoire interne. Cette copie se fait via la technologie DMA, une technologie de transfert de données entre mémoire RAM et périphérique qui n'utilise pas le processeur principal. Une fois la copie faite, le processeur de commande décode la commande et l’exécute sur la carte graphique. Il garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
Une commande utilise divers circuits de la carte 3D, de la mémoire vidéo, et d'autres ressources au sens large : des processeurs de ''shader'', notamment. La fonction principale du processeur de commande est de répartir le travail entre les différents circuits. Le processeur de commande décide à quel processeur ou circuit doit être envoyé une commande. C'est le processeur de commande qui s'occupe de la gestion des ressources et qui attribue telle ressource à telle commande.
A chaque instant, le processeur de commande répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le plus possible et évite au maximum les situations où ces circuits n'ont rien à faire. Sa tâche est compliquée par le fait que les cartes graphiques actuelles dupliquent leurs unités pour pouvoir faire beaucoup de calculs en parallèle. Elles ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gére les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
rpjowl8on9mmuplhqtfmb7ghvov9iy6
744034
744033
2025-06-03T13:51:04Z
Mewtow
31375
/* Le processeur de commandes */
744034
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le NV1 contenait dans le détail : un controleur DMA avec une IO-MMU intégrée, la FIFO de commande, le processeur de commande, un pipeline graphique, un pipeline sonore, un contrôleur de manette. L'ensemble était relié par un bus interne à la carte graphique. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète et l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Le processeur de commandes lit une commande dans le tampon de commande, décode la commande et l’exécute sur la carte graphique. Il garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
Une commande utilise divers circuits de la carte 3D, de la mémoire vidéo, et d'autres ressources au sens large (des processeurs de ''shader'', notamment). Aussi, le processeur de commande pilote les circuits de la carte graphique, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. La fonction principale du processeur de commande est de répartir le travail entre les différents circuits. Le processeur de commande décide à quel processeur ou circuit doit être envoyé une commande. C'est le processeur de commande qui s'occupe de la gestion des ressources et qui attribue telle ressource à telle commande.
A chaque instant, le processeur de commande répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le plus possible et évite au maximum les situations où ces circuits n'ont rien à faire. Sa tâche est compliquée par le fait que les cartes graphiques actuelles dupliquent leurs unités pour pouvoir faire beaucoup de calculs en parallèle. Elles ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gére les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
6hblv3izfmnw77b4h0cud01fc7pnx0d
744035
744034
2025-06-03T13:53:37Z
Mewtow
31375
/* Le processeur de commandes */
744035
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le NV1 contenait dans le détail : un controleur DMA avec une IO-MMU intégrée, la FIFO de commande, le processeur de commande, un pipeline graphique, un pipeline sonore, un contrôleur de manette. L'ensemble était relié par un bus interne à la carte graphique. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète et l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Le processeur de commandes lit une commande dans le tampon de commande, décode la commande et l’exécute sur la carte graphique. Il garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
Une commande utilise divers circuits de la carte 3D, de la mémoire vidéo, et d'autres ressources au sens large (des processeurs de ''shader'', notamment). Aussi, le processeur de commande pilote les circuits de la carte graphique, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. La fonction principale du processeur de commande est de répartir le travail entre les différents circuits. Le processeur de commande décide à quel processeur ou circuit doit être envoyé une commande. C'est le processeur de commande qui s'occupe de la gestion des ressources et qui attribue telle ressource à telle commande.
A chaque instant, le processeur de commande répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile. Un chapitre entier sera d'ailleurs dédié à ce sujet.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le mieux possible. Sa tâche est compliquée par le fait que les GPU récents ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
==Le fonctionnement du processeur de commandes==
Le processeur de commande lit, décode et exécute des commandes. Intuitivement, on se dit qu'il procède une commande à la fois. Mais les GPU modernes sont plus intelligents et peuvent exécuter plusieurs commandes en même temps, dans des circuits séparés. De nombreuses optimisations de ce type sont utilisées pour gagner en performance. Voyons comment un processeur de commande moderne fonctionne.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gére les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
d36a6dam6qp7nmj2mdks0wbyxdxeo8v
744036
744035
2025-06-03T13:54:49Z
Mewtow
31375
/* Le tampon de commandes */
744036
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète et l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Le processeur de commandes lit une commande dans le tampon de commande, décode la commande et l’exécute sur la carte graphique. Il garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
Une commande utilise divers circuits de la carte 3D, de la mémoire vidéo, et d'autres ressources au sens large (des processeurs de ''shader'', notamment). Aussi, le processeur de commande pilote les circuits de la carte graphique, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. La fonction principale du processeur de commande est de répartir le travail entre les différents circuits. Le processeur de commande décide à quel processeur ou circuit doit être envoyé une commande. C'est le processeur de commande qui s'occupe de la gestion des ressources et qui attribue telle ressource à telle commande.
A chaque instant, le processeur de commande répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile. Un chapitre entier sera d'ailleurs dédié à ce sujet.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le mieux possible. Sa tâche est compliquée par le fait que les GPU récents ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
==Le fonctionnement du processeur de commandes==
Le processeur de commande lit, décode et exécute des commandes. Intuitivement, on se dit qu'il procède une commande à la fois. Mais les GPU modernes sont plus intelligents et peuvent exécuter plusieurs commandes en même temps, dans des circuits séparés. De nombreuses optimisations de ce type sont utilisées pour gagner en performance. Voyons comment un processeur de commande moderne fonctionne.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gére les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
3xsa0467zoahvmttmmiqfi81t4iprd9
744037
744036
2025-06-03T13:55:37Z
Mewtow
31375
/* Le processeur de commandes */
744037
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète et l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Le processeur de commandes lit une commande dans le tampon de commande, décode la commande et l’exécute sur la carte graphique. Il garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
Une commande utilise divers circuits de la carte 3D, de la mémoire vidéo, et d'autres ressources au sens large (des processeurs de ''shader'', notamment). Pour donner un exemple, la première carte graphique de NVIDIA, le NV1, contenait dans le détail : un controleur DMA avec une IO-MMU intégrée, la FIFO de commande, le processeur de commande, un pipeline graphique, un pipeline sonore, un contrôleur de manette. L'ensemble était relié par un bus interne à la carte graphique. Le processeur de commande servait de gestionnaire principal.
Le processeur de commande pilote les circuits de la carte graphique, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. La fonction principale du processeur de commande est de répartir le travail entre les différents circuits. Le processeur de commande décide à quel processeur ou circuit doit être envoyé une commande. C'est le processeur de commande qui s'occupe de la gestion des ressources et qui attribue telle ressource à telle commande.
A chaque instant, le processeur de commande répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile. Un chapitre entier sera d'ailleurs dédié à ce sujet.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le mieux possible. Sa tâche est compliquée par le fait que les GPU récents ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
==Le fonctionnement du processeur de commandes==
Le processeur de commande lit, décode et exécute des commandes. Intuitivement, on se dit qu'il procède une commande à la fois. Mais les GPU modernes sont plus intelligents et peuvent exécuter plusieurs commandes en même temps, dans des circuits séparés. De nombreuses optimisations de ce type sont utilisées pour gagner en performance. Voyons comment un processeur de commande moderne fonctionne.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gére les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
qynv97pw0ullmt1d9g2iftk48kg4pdk
744038
744037
2025-06-03T14:07:13Z
Mewtow
31375
/* Le processeur de commandes */
744038
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète et l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Le processeur de commandes lit une commande dans le tampon de commande, décode la commande et l’exécute sur la carte graphique. Il garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
Une commande utilise divers circuits de la carte 3D, de la mémoire vidéo, et d'autres ressources au sens large (des processeurs de ''shader'', notamment). Pour donner un exemple, la première carte graphique de NVIDIA, le NV1, contenait dans le détail : un controleur DMA avec une IO-MMU intégrée, la FIFO de commande, le processeur de commande, un pipeline graphique, un pipeline sonore, un contrôleur de manette. L'ensemble était relié par un bus interne à la carte graphique. Le processeur de commande servait de gestionnaire principal.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
Le processeur de commande pilote les circuits de la carte graphique, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. La fonction principale du processeur de commande est de répartir le travail entre les différents circuits. Le processeur de commande décide à quel processeur ou circuit doit être envoyé une commande. C'est le processeur de commande qui s'occupe de la gestion des ressources et qui attribue telle ressource à telle commande.
A chaque instant, le processeur de commande répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile. Un chapitre entier sera d'ailleurs dédié à ce sujet.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le mieux possible. Sa tâche est compliquée par le fait que les GPU récents ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
==Le fonctionnement du processeur de commandes==
Le processeur de commande lit, décode et exécute des commandes. Intuitivement, on se dit qu'il procède une commande à la fois. Mais les GPU modernes sont plus intelligents et peuvent exécuter plusieurs commandes en même temps, dans des circuits séparés. De nombreuses optimisations de ce type sont utilisées pour gagner en performance. Voyons comment un processeur de commande moderne fonctionne.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gére les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
aaxj8lvkma159v81rxhge6n2t7atbph
744039
744038
2025-06-03T14:17:38Z
Mewtow
31375
/* Le processeur de commandes */
744039
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète et l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Une commande utilise divers circuits de la carte 3D, de la mémoire vidéo, et d'autres ressources au sens large (des processeurs de ''shader'', notamment). Pour donner un exemple, prenons la première carte graphique de NVIDIA, le NV1. Il s'agissait d'une carte multimédia qui incorporait non seulement une carte 2D/3D, mais aussi une carte son un contrôleur de disque et de quoi communiquer avec des manettes. De plus, il incorporait un contrôleur DMA pour échanger des données entre RAM système et les autres circuits. Et à tout cela, il fallait ajouter la FIFO de commandes et le processeur de commande. L'ensemble était relié par un bus interne à la carte graphique.
Le processeur de commande servait de gestionnaire principal. Il envoyait des ordres interne aux circuits de rendu 3D, à sa carte son, au contrôleur DMA. Par exemple, lorsque le processeur voulait copier des données en mémoire vidéo, il envoyait une commande de copie, qui était stockée dans le tampon de commande, puis était exécutée par le processeur de commande. Le processeur de commande envoyait alors les ordres adéquats au contrôleur DMA, qui faisait la copie des données.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
Le processeur de commandes lit une commande dans le tampon de commande, décode la commande et l’exécute sur la carte graphique. Il garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
Le processeur de commande pilote les circuits de la carte graphique, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. La fonction principale du processeur de commande est de répartir le travail entre les différents circuits. Le processeur de commande décide à quel processeur ou circuit doit être envoyé une commande. C'est le processeur de commande qui s'occupe de la gestion des ressources et qui attribue telle ressource à telle commande.
A chaque instant, le processeur de commande répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile. Un chapitre entier sera d'ailleurs dédié à ce sujet.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le mieux possible. Sa tâche est compliquée par le fait que les GPU récents ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
==Le fonctionnement du processeur de commandes==
Le processeur de commande lit, décode et exécute des commandes. Intuitivement, on se dit qu'il procède une commande à la fois. Mais les GPU modernes sont plus intelligents et peuvent exécuter plusieurs commandes en même temps, dans des circuits séparés. De nombreuses optimisations de ce type sont utilisées pour gagner en performance. Voyons comment un processeur de commande moderne fonctionne.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gére les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
l28htpqczxe3lslqksy9ny04mi7k0fr
744040
744039
2025-06-03T14:20:32Z
Mewtow
31375
/* Le processeur de commandes */
744040
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète, l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Lors de l'exécution d'une commande, le processeur de commande pilote les circuits de la carte graphique. Précisément, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. Le processeur de commande décide à quel processeur ou circuit doit être envoyé une commande.
Pour donner un exemple, prenons la première carte graphique de NVIDIA, le NV1. Il s'agissait d'une carte multimédia qui incorporait non seulement une carte 2D/3D, mais aussi une carte son un contrôleur de disque et de quoi communiquer avec des manettes. De plus, il incorporait un contrôleur DMA pour échanger des données entre RAM système et les autres circuits. Et à tout cela, il fallait ajouter la FIFO de commandes et le processeur de commande. L'ensemble était relié par un bus interne à la carte graphique.
Le processeur de commande servait de gestionnaire principal. Il envoyait des ordres interne aux circuits de rendu 3D, à sa carte son, au contrôleur DMA. Par exemple, lorsque le processeur voulait copier des données en mémoire vidéo, il envoyait une commande de copie, qui était stockée dans le tampon de commande, puis était exécutée par le processeur de commande. Le processeur de commande envoyait alors les ordres adéquats au contrôleur DMA, qui faisait la copie des données. Lors d'un rendu 3D, une commande de rendu 3D est envoyée au NV1, le processeur de commande la traite et l'envoi dans les circuits de rendu 3D.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
Le processeur de commandes garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
A chaque instant, le processeur de commande répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile. Un chapitre entier sera d'ailleurs dédié à ce sujet.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le mieux possible. Sa tâche est compliquée par le fait que les GPU récents ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
==Le fonctionnement du processeur de commandes==
Le processeur de commande lit, décode et exécute des commandes. Intuitivement, on se dit qu'il procède une commande à la fois. Mais les GPU modernes sont plus intelligents et peuvent exécuter plusieurs commandes en même temps, dans des circuits séparés. De nombreuses optimisations de ce type sont utilisées pour gagner en performance. Voyons comment un processeur de commande moderne fonctionne.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gére les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
5p3cvjkutz493a9ow3kmvdkp17bbpis
744041
744040
2025-06-03T14:24:34Z
Mewtow
31375
/* Le processeur de commandes */
744041
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète, l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Lors de l'exécution d'une commande, le processeur de commande pilote les circuits de la carte graphique. Précisément, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. Le processeur de commande décide à quel processeur ou circuit doit être envoyé une commande.
Pour donner un exemple, prenons la première carte graphique de NVIDIA, le NV1. Il s'agissait d'une carte multimédia qui incorporait non seulement une carte 2D/3D, mais aussi une carte son un contrôleur de disque et de quoi communiquer avec des manettes. De plus, il incorporait un contrôleur DMA pour échanger des données entre RAM système et les autres circuits. Et à tout cela, il fallait ajouter la FIFO de commandes et le processeur de commande. L'ensemble était relié par un bus interne à la carte graphique.
Le processeur de commande servait de gestionnaire principal. Il envoyait des ordres internes aux circuits de rendu 3D, à sa carte son, au contrôleur DMA. Par exemple, lorsque le processeur voulait copier des données en mémoire vidéo, il envoyait une commande de copie, qui était stockée dans le tampon de commande, puis était exécutée par le processeur de commande. Le processeur de commande envoyait alors les ordres adéquats au contrôleur DMA, qui faisait la copie des données. Lors d'un rendu 3D, une commande de rendu 3D est envoyée au NV1, le processeur de commande la traite et l'envoi dans les circuits de rendu 3D.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
A chaque instant, le processeur de commande répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile. Un chapitre entier sera d'ailleurs dédié à ce sujet.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le mieux possible. Sa tâche est compliquée par le fait que les GPU récents ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
Le processeur de commande n'a pas qu'un rôle de répartiteur. Il connait à tout instant l'état de la carte graphique, l'état de chaque sous-circuit. L'implémentation est variable suivant le GPU. La plus simple ajoute un registre d'état à chaque circuit, qui, est consultable en temps réel par le processeur de commande. Mais il est possible d'utiliser un système d'interruptions interne à la puce, ou circuits préviennent le processeur de commande quand ils ont terminé leur travail. Grâce à cela, le processeur de commandes garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
==Le fonctionnement du processeur de commandes==
Le processeur de commande lit, décode et exécute des commandes. Intuitivement, on se dit qu'il procède une commande à la fois. Mais les GPU modernes sont plus intelligents et peuvent exécuter plusieurs commandes en même temps, dans des circuits séparés. De nombreuses optimisations de ce type sont utilisées pour gagner en performance. Voyons comment un processeur de commande moderne fonctionne.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gére les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
74kzthjlkdrlrx920c4mcbxr7bxzwz0
744042
744041
2025-06-03T14:25:47Z
Mewtow
31375
/* Le processeur de commandes */
744042
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète, l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Lors de l'exécution d'une commande, le processeur de commande pilote les circuits de la carte graphique. Précisément, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. Le processeur de commande décide à quel processeur ou circuit doit être envoyé une commande.
Pour donner un exemple, prenons la première carte graphique de NVIDIA, le NV1. Il s'agissait d'une carte multimédia qui incorporait non seulement une carte 2D/3D, mais aussi une carte son un contrôleur de disque et de quoi communiquer avec des manettes. De plus, il incorporait un contrôleur DMA pour échanger des données entre RAM système et les autres circuits. Et à tout cela, il fallait ajouter la FIFO de commandes et le processeur de commande. L'ensemble était relié par un bus interne à la carte graphique.
Le processeur de commande servait de gestionnaire principal. Il envoyait des ordres internes aux circuits de rendu 3D, à sa carte son, au contrôleur DMA. Par exemple, lorsque le processeur voulait copier des données en mémoire vidéo, il envoyait une commande de copie, qui était stockée dans le tampon de commande, puis était exécutée par le processeur de commande. Le processeur de commande envoyait alors les ordres adéquats au contrôleur DMA, qui faisait la copie des données. Lors d'un rendu 3D, une commande de rendu 3D est envoyée au NV1, le processeur de commande la traite et l'envoi dans les circuits de rendu 3D.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
Les GPU modernes sont plus complexes, ce qui fait que le processeur de commande a plus de travail. Parmis les nombreuses tâches qui lui sont confiées, il répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile. Un chapitre entier sera d'ailleurs dédié à ce sujet.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le mieux possible. Sa tâche est compliquée par le fait que les GPU récents ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
Le processeur de commande n'a pas qu'un rôle de répartiteur. Il connait à tout instant l'état de la carte graphique, l'état de chaque sous-circuit. L'implémentation est variable suivant le GPU. La plus simple ajoute un registre d'état à chaque circuit, qui, est consultable en temps réel par le processeur de commande. Mais il est possible d'utiliser un système d'interruptions interne à la puce, ou circuits préviennent le processeur de commande quand ils ont terminé leur travail. Grâce à cela, le processeur de commandes garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
==Le fonctionnement du processeur de commandes==
Le processeur de commande lit, décode et exécute des commandes. Intuitivement, on se dit qu'il procède une commande à la fois. Mais les GPU modernes sont plus intelligents et peuvent exécuter plusieurs commandes en même temps, dans des circuits séparés. De nombreuses optimisations de ce type sont utilisées pour gagner en performance. Voyons comment un processeur de commande moderne fonctionne.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gére les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
158wrzpajbsh0h3zhjc3bv2zawutqg9
744043
744042
2025-06-03T14:26:11Z
Mewtow
31375
/* Le processeur de commandes */
744043
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète, l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Lors de l'exécution d'une commande, le processeur de commande pilote les circuits de la carte graphique. Précisément, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. Pour donner un exemple, prenons la première carte graphique de NVIDIA, le NV1. Il s'agissait d'une carte multimédia qui incorporait non seulement une carte 2D/3D, mais aussi une carte son un contrôleur de disque et de quoi communiquer avec des manettes. De plus, il incorporait un contrôleur DMA pour échanger des données entre RAM système et les autres circuits. Et à tout cela, il fallait ajouter la FIFO de commandes et le processeur de commande. L'ensemble était relié par un bus interne à la carte graphique.
Le processeur de commande servait de gestionnaire principal. Il envoyait des ordres internes aux circuits de rendu 3D, à sa carte son, au contrôleur DMA. Par exemple, lorsque le processeur voulait copier des données en mémoire vidéo, il envoyait une commande de copie, qui était stockée dans le tampon de commande, puis était exécutée par le processeur de commande. Le processeur de commande envoyait alors les ordres adéquats au contrôleur DMA, qui faisait la copie des données. Lors d'un rendu 3D, une commande de rendu 3D est envoyée au NV1, le processeur de commande la traite et l'envoi dans les circuits de rendu 3D.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
Les GPU modernes sont plus complexes, ce qui fait que le processeur de commande a plus de travail. Parmis les nombreuses tâches qui lui sont confiées, il répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile. Un chapitre entier sera d'ailleurs dédié à ce sujet.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le mieux possible. Sa tâche est compliquée par le fait que les GPU récents ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
Le processeur de commande n'a pas qu'un rôle de répartiteur. Il connait à tout instant l'état de la carte graphique, l'état de chaque sous-circuit. L'implémentation est variable suivant le GPU. La plus simple ajoute un registre d'état à chaque circuit, qui, est consultable en temps réel par le processeur de commande. Mais il est possible d'utiliser un système d'interruptions interne à la puce, ou circuits préviennent le processeur de commande quand ils ont terminé leur travail. Grâce à cela, le processeur de commandes garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
==Le fonctionnement du processeur de commandes==
Le processeur de commande lit, décode et exécute des commandes. Intuitivement, on se dit qu'il procède une commande à la fois. Mais les GPU modernes sont plus intelligents et peuvent exécuter plusieurs commandes en même temps, dans des circuits séparés. De nombreuses optimisations de ce type sont utilisées pour gagner en performance. Voyons comment un processeur de commande moderne fonctionne.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gére les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
h3hs943q28nttedmuxtaupa1gj1aaeo
744044
744043
2025-06-03T14:27:21Z
Mewtow
31375
/* Le fonctionnement du processeur de commandes */
744044
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète, l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Lors de l'exécution d'une commande, le processeur de commande pilote les circuits de la carte graphique. Précisément, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. Pour donner un exemple, prenons la première carte graphique de NVIDIA, le NV1. Il s'agissait d'une carte multimédia qui incorporait non seulement une carte 2D/3D, mais aussi une carte son un contrôleur de disque et de quoi communiquer avec des manettes. De plus, il incorporait un contrôleur DMA pour échanger des données entre RAM système et les autres circuits. Et à tout cela, il fallait ajouter la FIFO de commandes et le processeur de commande. L'ensemble était relié par un bus interne à la carte graphique.
Le processeur de commande servait de gestionnaire principal. Il envoyait des ordres internes aux circuits de rendu 3D, à sa carte son, au contrôleur DMA. Par exemple, lorsque le processeur voulait copier des données en mémoire vidéo, il envoyait une commande de copie, qui était stockée dans le tampon de commande, puis était exécutée par le processeur de commande. Le processeur de commande envoyait alors les ordres adéquats au contrôleur DMA, qui faisait la copie des données. Lors d'un rendu 3D, une commande de rendu 3D est envoyée au NV1, le processeur de commande la traite et l'envoi dans les circuits de rendu 3D.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
Les GPU modernes sont plus complexes, ce qui fait que le processeur de commande a plus de travail. Parmis les nombreuses tâches qui lui sont confiées, il répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile. Un chapitre entier sera d'ailleurs dédié à ce sujet.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le mieux possible. Sa tâche est compliquée par le fait que les GPU récents ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
Le processeur de commande n'a pas qu'un rôle de répartiteur. Il connait à tout instant l'état de la carte graphique, l'état de chaque sous-circuit. L'implémentation est variable suivant le GPU. La plus simple ajoute un registre d'état à chaque circuit, qui, est consultable en temps réel par le processeur de commande. Mais il est possible d'utiliser un système d'interruptions interne à la puce, ou circuits préviennent le processeur de commande quand ils ont terminé leur travail. Grâce à cela, le processeur de commandes garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
Pour résumer, le processeur de commande lit une commande, répartit le travail sur les différents circuits du GPU, et vérifie en permanence leur état pour déterminer si la commande est en pause, terminée, en cours d'exécution, etc.
==Le fonctionnement du processeur de commandes==
Intuitivement, on se dit que le processeur de commande procède une commande à la fois. Mais les GPU modernes sont plus intelligents et peuvent exécuter plusieurs commandes en même temps, dans des circuits séparés. De nombreuses optimisations de ce type sont utilisées pour gagner en performance. Voyons comment un processeur de commande moderne fonctionne.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gére les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
2r4tr591ub3qntygyo65rlq7o47fec8
744055
744044
2025-06-03T17:00:38Z
Mewtow
31375
/* L'arbitrage accéléré par le GPU */
744055
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète, l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Lors de l'exécution d'une commande, le processeur de commande pilote les circuits de la carte graphique. Précisément, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. Pour donner un exemple, prenons la première carte graphique de NVIDIA, le NV1. Il s'agissait d'une carte multimédia qui incorporait non seulement une carte 2D/3D, mais aussi une carte son un contrôleur de disque et de quoi communiquer avec des manettes. De plus, il incorporait un contrôleur DMA pour échanger des données entre RAM système et les autres circuits. Et à tout cela, il fallait ajouter la FIFO de commandes et le processeur de commande. L'ensemble était relié par un bus interne à la carte graphique.
Le processeur de commande servait de gestionnaire principal. Il envoyait des ordres internes aux circuits de rendu 3D, à sa carte son, au contrôleur DMA. Par exemple, lorsque le processeur voulait copier des données en mémoire vidéo, il envoyait une commande de copie, qui était stockée dans le tampon de commande, puis était exécutée par le processeur de commande. Le processeur de commande envoyait alors les ordres adéquats au contrôleur DMA, qui faisait la copie des données. Lors d'un rendu 3D, une commande de rendu 3D est envoyée au NV1, le processeur de commande la traite et l'envoi dans les circuits de rendu 3D.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
Les GPU modernes sont plus complexes, ce qui fait que le processeur de commande a plus de travail. Parmis les nombreuses tâches qui lui sont confiées, il répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile. Un chapitre entier sera d'ailleurs dédié à ce sujet.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le mieux possible. Sa tâche est compliquée par le fait que les GPU récents ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
Le processeur de commande n'a pas qu'un rôle de répartiteur. Il connait à tout instant l'état de la carte graphique, l'état de chaque sous-circuit. L'implémentation est variable suivant le GPU. La plus simple ajoute un registre d'état à chaque circuit, qui, est consultable en temps réel par le processeur de commande. Mais il est possible d'utiliser un système d'interruptions interne à la puce, ou circuits préviennent le processeur de commande quand ils ont terminé leur travail. Grâce à cela, le processeur de commandes garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
Pour résumer, le processeur de commande lit une commande, répartit le travail sur les différents circuits du GPU, et vérifie en permanence leur état pour déterminer si la commande est en pause, terminée, en cours d'exécution, etc.
==Le fonctionnement du processeur de commandes==
Intuitivement, on se dit que le processeur de commande procède une commande à la fois. Mais les GPU modernes sont plus intelligents et peuvent exécuter plusieurs commandes en même temps, dans des circuits séparés. De nombreuses optimisations de ce type sont utilisées pour gagner en performance. Voyons comment un processeur de commande moderne fonctionne.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gère les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
8bw2xskv9g4ddqpbcwswdw44f5a4jsy
744056
744055
2025-06-03T17:10:57Z
Mewtow
31375
/* L'arbitrage de l'accès à la carte graphique */
744056
wikitext
text/x-wiki
Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.
==Le pilote de carte graphique==
Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.
Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux. Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.
===La gestion des interruptions===
Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé.
Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.
===La compilation des ''shaders''===
Le pilote de carte graphique est aussi chargé de traduire les ''shaders'' en code machine. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les ''shaders'' sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les ''shaders'' sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres. En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des ''shaders'', non pas par l'utilisateur. Par exemple, les ''shaders'' d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.
L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. Autant dire que cela économise plus le processeur que si on devait compiler complètement les ''shaders'' à chaque exécution.
Fait amusant, il faut savoir que le pilote peut parfois remplacer les ''shaders'' d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des ''shaders'' alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le ''shader'' originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le ''shader'' alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.
Enfin, certains ''shaders'' sont fournis par le pilote pour d'autres raisons. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de ''shaders'', présents dans le pilote de carte graphique.
===Les autres fonctions===
Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.
==Les commandes graphiques/vidéo/autres : des ordres envoyés au GPU==
Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de '''commandes'''. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.
Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.
Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.
Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.
{|class="wikitable"
|-
! Commandes 2D
! Fonction
|-
|PAINT
|Peindre un rectangle d'une certaine couleur
|-
|PAINT_MULTI
|Peindre des rectangles (pas les mêmes paramètres que PAINT)
|-
|BITBLT
|Copie d'un bloc de mémoire dans un autre
|-
|BITBLT_MULTI
|Plusieurs copies de blocs de mémoire dans d'autres
|-
|TRANS_BITBLT
|Copie de blocs de mémoire avec un masque
|-
|NEXTCHAR
|Afficher un caractère avec une certaine couleur
|-
|HOSTDATA_BLT
|Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
|-
|POLYLINE
|Afficher des lignes reliées entre elles
|-
|POLYSCANLINES
|Afficher des lignes
|-
|PLY_NEXTSCAN
|Afficher plusieurs lignes simples
|-
|SET_SCISSORS
|Utiliser des coupes (ciseaux)
|-
|LOAD_PALETTE
|Charger la palette pour affichage 2D
|}
===Le tampon de commandes===
L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans le '''tampon de commandes'''.
Le tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que le tampon de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.
Le tampon de commande est soit une mémoire FIFO séparée, soit une portion de la mémoire vidéo. Le NV1, la toute première carte graphique NVIDIA, utilisait une mémoire FIFO intégrée à la carte graphique, séparée des autres circuits. Le processeur envoyait les commandes par rafale au GPU, à savoir qu'il envoyait plusieurs dizaines ou centaines de commandes à la suite, avant de passer à autre chose. Le GPU traitait les commandes une par une, à un rythme bien plus lent. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.
: Le fonctionnement de la FIFO du NV1 est décrit dans le brevet ''US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs''.
===Le processeur de commandes===
Le '''processeur de commande''' est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète, l’exécute, puis passe à la suivante. Le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.
Lors de l'exécution d'une commande, le processeur de commande pilote les circuits de la carte graphique. Précisément, il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. Pour donner un exemple, prenons la première carte graphique de NVIDIA, le NV1. Il s'agissait d'une carte multimédia qui incorporait non seulement une carte 2D/3D, mais aussi une carte son un contrôleur de disque et de quoi communiquer avec des manettes. De plus, il incorporait un contrôleur DMA pour échanger des données entre RAM système et les autres circuits. Et à tout cela, il fallait ajouter la FIFO de commandes et le processeur de commande. L'ensemble était relié par un bus interne à la carte graphique.
Le processeur de commande servait de gestionnaire principal. Il envoyait des ordres internes aux circuits de rendu 3D, à sa carte son, au contrôleur DMA. Par exemple, lorsque le processeur voulait copier des données en mémoire vidéo, il envoyait une commande de copie, qui était stockée dans le tampon de commande, puis était exécutée par le processeur de commande. Le processeur de commande envoyait alors les ordres adéquats au contrôleur DMA, qui faisait la copie des données. Lors d'un rendu 3D, une commande de rendu 3D est envoyée au NV1, le processeur de commande la traite et l'envoi dans les circuits de rendu 3D.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
Les GPU modernes sont plus complexes, ce qui fait que le processeur de commande a plus de travail. Parmis les nombreuses tâches qui lui sont confiées, il répartit les ''shaders'' sur les processeurs de ''shaders'', en faisant en sorte qu'ils soient utilisés le plus possible. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile. Un chapitre entier sera d'ailleurs dédié à ce sujet.
Il envoie aussi certaines commandes aux circuits fixes, comme l’''input assembler''. Là encore, il faut en sorte que les circuits fixes soient utilisés le mieux possible. Sa tâche est compliquée par le fait que les GPU récents ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du processeur de commande que de répartir les calculs sur ces différentes unités. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.
[[File:Architecture de base d'une carte 3D - 1.png|centre|vignette|upright=2.5|Architecture de base d'une carte 3D - 1]]
Le processeur de commande n'a pas qu'un rôle de répartiteur. Il connait à tout instant l'état de la carte graphique, l'état de chaque sous-circuit. L'implémentation est variable suivant le GPU. La plus simple ajoute un registre d'état à chaque circuit, qui, est consultable en temps réel par le processeur de commande. Mais il est possible d'utiliser un système d'interruptions interne à la puce, ou circuits préviennent le processeur de commande quand ils ont terminé leur travail. Grâce à cela, le processeur de commandes garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc.
Pour résumer, le processeur de commande lit une commande, répartit le travail sur les différents circuits du GPU, et vérifie en permanence leur état pour déterminer si la commande est en pause, terminée, en cours d'exécution, etc.
==Le fonctionnement du processeur de commandes==
Intuitivement, on se dit que le processeur de commande procède une commande à la fois. Mais les GPU modernes sont plus intelligents et peuvent exécuter plusieurs commandes en même temps, dans des circuits séparés. De nombreuses optimisations de ce type sont utilisées pour gagner en performance. Voyons comment un processeur de commande moderne fonctionne.
===L'ordonnancement et la gestion des ressources===
Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, si tous les processeurs de ''vertex shader'' sont occupés, l’''input assembler'' ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’''input assembly'' est mise en pause en attendant qu'un processeur de ''shader'' soit libre.
La même chose a lieu pour l'étape de rastérisation : si aucun processeur de ''shader'' n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de ''shader'' veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les ''pixel shader'' ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc.
En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.
===Le pipelining des commandes===
Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.
L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de ''shaders'' et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.
Le processeur de commande gère donc plusieurs commandes simultanées, à savoir plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.
Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.
Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de ''shaders''. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de ''shaders'' inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.
===La synchronisation de l’exécution des commandes avec le processeur===
Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.
Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.
Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.
Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du ''pooling'', où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.
Pour faciliter l'implémentation du ''pooling'', la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.
Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des '''commandes de synchronisation''' : les barrières (''fences''). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les ''shaders'' ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".
===La synchronisation de l’exécution des commandes intra-GPU===
Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.
Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !
Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des '''commandes de sémaphore'''. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".
Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Avec ce modèle, les sémaphores bloquent une commande tant qu'une ressource (une texture) est utilisée par une autre commande.
{|class="wikitable"
|-
! Commandes de synchronisation
! Fonction
|-
|NOP
|Ne rien faire
|-
|WAIT_SEMAPHORE
|Attendre la synchronisation avec un sémaphore
|-
|WAIT_MEM
|Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU
|}
==L'arbitrage de l'accès à la carte graphique==
Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en ''streaming'' sur votre navigateur web, avec un programme de ''cloud computing'' de type ''Folding@Home'' qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de ''cloud computing'' va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est soit le pilote qui s'en charge, soit la carte graphique elle-même.
===L'arbitrage logiciel du GPU===
L'arbitrage logiciel est la méthode la plus simple. Concrètement, c'est le système d'exploitation et/ou le pilote de la carte graphique qui gèrent l'arbitrage du GPU. Dans les deux cas, tout est fait en logiciel. Les commandes sont accumulées dans un tampon de commande, voire plusieurs, et l'OS/pilote décide quelle commande envoyer en priorité.
Sur Windows, avant l'arrivée du modèle de driver dit ''Windows Display Driver Model'', de telles situations étaient gérées simplement. Les applications ajoutaient des commandes dans une file de commande unique. Les commandes étaient exécutées dans l'ordre, en mode premier entrée dans la file premier sorti/exécuté. Il n'y avait pour ainsi dire pas d'arbitrage entre les applications. Et ce n'était pas un problème car, à l'époque, les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran.
Avec le modèle de driver ''Windows Display Driver Model'' (WDDM), le GPU est maintenant arbitré, à savoir que les applications ont accès à tour de rôle au GPU, certaines ayant droit à plus de temps GPU que d'autres. Chaque programme a accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.
Une autre fonctionnalité de WDDM est que les applications utilisant le GPU peuvent se partager la mémoire vidéo sans se marcher dessus. Avant, toutes les applications avaient accès à toute la mémoire vidéo, bien que ce soit par l’intermédiaire de commandes spécifiques. Mais avec WDDM, chaque application dispose de sa propre portion de mémoire vidéo, à laquelle est la seule à avoir accès. Une application ne peut plus lire le contenu réel de la mémoire vidéo, et encore moins lire le contenu des autres applications.
: Il s'agit d'un équivalent à l'isolation des processus pour les programmes Windows, mais appliqué à la mémoire vidéo et non la mémoire système. Et cette isolation est rendue d'autant plus simple que les GPU modernes implémentent un système de mémoire virtuelle à pagination très poussé, avec du swap en mémoire RAM système, une protection mémoire, et bien d'autres fonctionnalités. Nous en reparlerons dans les chapitres sur la mémoire vidéo.
===L'arbitrage accéléré par le GPU===
Depuis 2020, avec l'arrivée de la ''Windows 10 May 2020 update'', Windows est capable de déléguer l'arbitrage du GPU au GPU lui-même. Le GPU gère lui-même l'arbitrage, du moins en partie. En partie, car l'OS gère les priorité, à savoir quelle application a droit à plus de temps que les autres. Par contre, la gestion du ''quantum'' de temps ou les commutations de contexte (on stoppe une application pour laisser la place à une autre), est du fait du GPU lui-même.
L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Un autre avantage est que le GPU peut gérer plus finement les ''quantum'' de temps et la commutation de contexte. Le CPU principal gère des durées assez importantes, de l'ordre de la milliseconde. De plus, il doit communiquer avec le GPU par des mécanismes temporellement imprécis, comme des interruptions. Avec l'arbitrage accéléré par le GPU, le GPU n'a plus besoin de communiquer avec le CPU pour l'arbitrage et peut le faire plus précisément, plus immédiatement. Un gain en performance théorique est donc possible.
L'arbitrage accéléré par le GPU est le fait soit du processeur de commandes, soit d'un processeur spécialisé, séparé.
===L'exemple du NV1===
La carte NVIDIA NV1 avait diverses optimisations pour supporter plusieurs applications à la fois. L'une d'entre elle est le support de plusieurs tampons de commande. La carte graphique gérait en tout 128 tampons de commandes, chacun contenant 32 commandes consécutives. Le tout permettait à 128 logiciels différents d'avoir chacun son propre tampon de commande. L'implémentation du NV1 utilisait en réalité une FIFO unique, dans une mémoire RAM unique, qui était segmentée en 128 sous-FIFOs. Mais il est techniquement possible d'utiliser plusieurs FIFOs séparées connectées à un multiplexeur en sortie et un démultiplexeur en entrée.
Le NV1 utilisait des adresses de 23 bits, ce qui fait 8 méga-octets de RAM. Les 8 méga-octets étaient découpées en 128 blocs de mémoire consécutifs, chacun étant associé à une application et faisant 64 kilo-octets. Les adresses de 23 bits étaient donc découpées en une portion de 7 bit pour identifier le logiciel qui envoie la commande, et une portion de 16 bits pour identifier la position des données dans le bloc de RAM. Une entrée dans la FIFO du NV1 faisait 48 bits, contenant une donnée de 32 bits et les 16 bits de l'adresse.
{{NavChapitre | book=Les cartes graphiques
| prev=La hiérarchie mémoire d'un GPU
| prevText=La hiérarchie mémoire d'un GPU
| next=Le pipeline géométrique d'avant DirectX 10
| netxText=Le pipeline géométrique d'avant DirectX 10
}}{{autocat}}
hgza7ynnnsy1rde0qmd3o85i3p9zfw3
Neurosciences/Le métabolisme cérébral
0
70529
744030
743943
2025-06-03T13:18:31Z
Mewtow
31375
/* Le fer */
744030
wikitext
text/x-wiki
Le cerveau est un organe qui consomme beaucoup d'énergie : près de 20% de la consommation énergétique du corps est de son fait, alors qu'il ne représente que 2 % de sa masse. Il faut dire que faire fonctionner les neurones demande de l'énergie, d'autant plus que ceux-ci sont actifs. Générer des potentiels d'action demande une certaine énergie, produire des neurotransmetteurs aussi, sans compter l'action des pompes et canaux ioniques de la membrane. Le cerveau utilise la majeure partie de son énergie pour faire fonctionner ses pompes et canaux ioniques, principalement les pompes. Celles-ci vont, pour rappel, pomper certains ions en dehors de la cellule, contre leur gradient de concentration. Cela demande naturellement de l'énergie, pour contrer l'effet de la concentration plus importante dans le milieu extérieur. Chaque pompe va ainsi utiliser une ou plusieurs molécules d'ATP pour faire sortir un ion. Cela ne parait n'être pas grand-chose, mais cela compte pour 50% du métabolisme de base du cerveau !
==Le métabolisme énergétique du cerveau==
Le cerveau est un organe qui consomme beaucoup d'énergie. On estime qu'il est responsable, à lui seul d'environ 25 % de la consommation énergétique du corps. La valeur exacte varie beaucoup selon beaucoup de paramètres. Par exemple, on sait que la consommation énergétique du cerveau dépend de sa température. On estime qu'une réduction de 1°C réduit la consommation énergétique cérébrale de 6 à 5 %. Cela a d'ailleurs des applications thérapeutiques, dans les cas d'AVC, d'arrêt cardiaque ou d'autres situations similaires où le cerveau manque d'oxygène. Dans ces situations, on refroidit le cerveau pour ralentir son métabolisme. Cela réduit l'apparition de lésions cérébrales, causées par un métabolisme anormal lié au manque d'oxygène. Quoi qu’il en soit, le métabolisme cérébral dépend de bien d'autres paramètres, et il serait inutile d'en faire une liste exhaustive.
===Les sources d'énergie du cerveau===
Les neurones sont comme toutes les cellules : ils consomment des nutriments pour fabriquer de l'énergie, qu'ils emmagasinent sous forme d'ATP dans la cellule. En temps normal, ces nutriments sont des sucres, mais le cerveau peut aussi bruler des dérivés de la désagrégation des graisses ou des protéines si le sucre vient à manquer. Cette combustion des nutriments, implique toute une série de réactions chimiques très compliquées. Ceux qui s'y connaissent en biologie devraient connaitre les notions de fermentation ou de respiration cellulaires, voire le cycle de Krebs. Nous n'allons pas revenir sur ces notions fondamentales de la biologie, mais nous allons remarquer que la combustion des nutriments peut se faire soit en présence d'oxygène, soit sans. Quand les nutriments sont brulés en présence d'oxygène, les réactions chimiques sont des réactions dites de ''respiration cellulaire''. Dans le cas contraire, ce sont des réactions de ''fermentation''. Les neurones sont capables à la fois de respiration cellulaire que de fermentation. Vu que les neurones consomment beaucoup d'énergie, le processus principal est la respiration, ce qui fait que le cerveau a besoin d'oxygène pour fonctionner. Pour résumer, le cerveau a principalement besoin de sucres et d'oxygène, mais il peut consommer des graisses en cas de manque.
====Le glucose et l'oxygène====
En temps normal, le cerveau consomme du sucre par respiration cellulaire, la fermentation étant minimale. Le sucre le plus utilisé par le cerveau est le glucose, du moins en temps normal, mais des molécules similaires au glucose peuvent être utilisées en lieu de place de celui-ci : c'est le cas pour le pyruvate, le mannose et le lactate. Les expériences qui ont montré cela sont assez simples à expliquer. Elles se bornent à comparer la composition du sang entre les artères qui alimentent le cerveau et les veines qui en sortent. Ces mesures montrent alors que le sang est identique entre l'entrée et la sortie, si ce n'est que sa teneur en glucose et en oxygène est plus basse dans les veines que dans les artères, sans compter que sa teneur en dioxyde de carbone augmente.
Les mesures précédentes traduisent le fait que le cerveau utilise ce qu'on appelle la respiration cellulaire aérobie pour produire son énergie : il consomme de l'oxygène et du glucose et produit de l'énergie et du dioxyde de carbone. Cependant, ces mesures montrent que tout l'oxygène n'est pas converti de cette manière. Si tout le glucose était utilisé ainsi, 100 grammes de cerveau devraient consommer 26 millimoles de glucose par minutes. Or, la valeur mesurée est de 31 millimoles par minutes. Il y a donc une petite différence de 4,4 millimoles par minutes, qui est utilisée autrement. Dans le détail, on verra que ce glucose est transformé et stocké dans les astrocytes et les neurones, il est mis en réserve.
====Les lipides et acides gras====
Outre le glucose, les protéines et des dérivés de la désagrégation des graisses peuvent être consommés par le cerveau pour produire de l'énergie. Encore une fois, cette production s'effectue par le biais de la respiration cellulaire aérobie et le cerveau n'est pas le seul à pouvoir faire cela (tous les tissus peuvent brûler des protéines ou des lipides dans le cycle de Krebs). Par contre, le métabolisme cérébral des graisses est différent du métabolisme des autres tissus. En effet, le cerveau ne peut pas bruler les acides gras pour produire de l'énergie, contrairement aux autres tissus. La raison est que les acides gras ne peuvent pas traverser la barrière hémato-encéphalique. En revanche, le cerveau peut bruler les '''corps cétoniques''', des dérivés de la désagrégation des graisses qui peuvent traverser la barrière hémato-encéphalique et alimenter le cerveau en énergie. Parmi les corps cétoniques, deux alimentent le cerveau et le cœur en énergie : l'acétylacétate et le β-D-hydroxybutyrate.
Cependant, la production de corps cétoniques n'arrive que dans des conditions très particulières. Il faut que le corps manque de sucres (glucose) au point que le foie en est réduit à bruler des graisses. Le métabolisme des lipides et acides gras donne alors naissance à des corps cétoniques, par un processus dit de cétogenèse. Par exemple, cela arrive en cas de diabète ou de jeûne : l'alimentation en sucre étant alors inadéquate, le cerveau doit utiliser d'autres voies que la consommation de glucose. Un autre exemple est celui des nouveaux-nés allaités, dont l'alimentation est très riche en lipides et en acides gras. Dans tous les exemples précédents, les réactions chimiques utilisées pour produire de l'énergie à partir de lipides ou de protéines sont les mêmes : les voies métaboliques des nouveaux-nés sont réactivées en cas de jeûne ou de diabète.
===L'hypoxie cérébrale===
Quand l'oxygène vient à manquer, on fait face à une situation d'''hypoxie cérébrale''. Une telle situation arrive notamment lors d'un AVC ou d'un arrêt cardiaque, quand le cerveau n'est plus irrigué par le sang. L'oxygène n'est plus apporté au cerveau, qui réagit pour faire face à la pénurie. Le cerveau a beau avoir une grosse consommation énergétique, il a des réserves très faibles en nutriments. Ce qui fait que l'hypoxie commence à causer des problèmes en quelques minutes, voire quelques secondes. Il se produit alors une cascade de réactions chimiques, qui endommage les neurones et entraine leur mort. Cette cascade se produit en deux étapes : une première étape liée à la carence en oxygène, une autre quand le sang revient dans le cerveau et où l'oxygène revient. Ces deux étapes laissent des dommages cérébraux massifs, qu'il s'agisse du manque d'oxygène ou des dommages de la seconde étape, appelés dommages de re-perfusion.
====La cascade ischémique====
La première étape est une série de réactions chimiques qui s'appelle la '''cascade ischémique''', qui fait suite à un manque d'oxygène. Pour faire simple, le cerveau réagit en passant à des réactions de fermentation pour consommer les sucres. Mais l'usage de la fermentation pose divers problèmes. En premier lieu, elle ne fournit pas assez d'énergie pour les pompes et canaux ioniques. L'équilibre ionique du cerveau est alors perturbé, avec une accumulation d'ions dans ou en dehors des neurones. En second lieu, la fermentation produit des déchets qui sont toxiques pour les neurones. Or, ceux-ci peuvent entrainer des dommages s'ils ne sont pas évacués du cerveau. La majorité des dommages provient, de manière indirecte, du déséquilibre ionique, qui intoxique les neurones de l'intérieur.
Le dysfonctionnement principal est l'arrêt de certaines pompes ioniques, notamment des pompes au potassium, au calcium et au sodium. Cela mène à une ''accumulation de sodium et de calcium dans les neurones''. En premier lieu, le calcium va s'accumuler dans les neurones. Or, le calcium est toxique pour les cellules, ce qui peut forcer l'apoptose des cellules (leur suicide). De plus, ce calcium favorise la libération des neurotransmetteurs, dont le glutamate. Or, le glutamate fait rentrer encore plus de calcium via les récepteurs NMDA et AMPA. Rappelons que c'est pour cela que le glutamate a des propriétés excitotxiques, à savoir qu'il peut exciter les neurones à l'excès, jusqu’à en devenir toxique. Les effets excitotoxiques du glutamate proviennent de là, et ceux-ci s'expriment avec force lors de l'ischémie, bien plus que dans des conditions normales. Limiter les dégâts de la cascade ischémique demande d'agir vite afin de stopper celle-ci avant qu'un trop grand nombre de neurones soient morts. Une autre possibilité serait d'atténuer l'excitotoxicité du glutamate, en utilisant des antagonistes des récepteurs NMDA et AMPA. Mais cette stratégie n'a pas donné de bons résultats dans les études réalisées à l'heure actuelle (mi-2017).
Un autre défaut est que le sodium s'accumule dans les neurones, ce qui en perturbe l'équilibre hydroélectrique (pour les connaisseurs, l'équilibre osmotique). Les raisons à cela sont multiples. Déjà, les pompes ioniques calcium-sodium vont tenter d'évacuer le calcium dans les neurones, mais cela fait rentrer du sodium. De plus, certaines pompes ioniques potassium-sodium vont dysfonctionner, en raison du manque d'ATP, ce qui réduit l'élimination de sodium intraneuronale. Tout cela fait que les neurones tendent à accumuler beaucoup d'ions sodium dans leur cytoplasme. Sans rentrer dans les détails, cela a une conséquence : de l'eau s'accumule dans les neurones, qui gonflent. Il se produit alors un œdème cérébral, qui fait gonfler le cerveau et le compresse sur le crâne. Cette pression entraine des lésions assez graves, si elle est assez intense. Mais nous en reparlerons dans le chapitre sur la pression intracrânienne, qui parlera des œdèmes cérébraux en général.
Enfin, la fermentation produit des déchets métaboliques, les deux principaux étant : le lactate et des ions hydrogène. Les deux sont toxiques pour les neurones, quand ils sont en excès, ce qui est le cas lors d'une hypoxie cérébrale. L'accumulation de lactate est moins problématique que l'accumulation des ions hydrogène. Cette dernière fait que le pH diminue, entraînant une acidose métabolique, perturbant les réactions chimiques cérébrales.
[[File:Cascade ischémique 02.svg|centre|vignette|upright=3.0|Description simplifiée de la cascade ischémique.]]
==Les interactions neurones-astrocytes==
Les molécules dissoutes dans le sang doivent traverser la barrière hémato-encéphalique pour arriver aux neurones. Leur passage à travers celle-ci fait intervenir des molécules appelées ''transporteurs''. Pour rappel, ce sont des "récepteurs" qui permettent à une molécule de passer d'un côté à l'autre d'une membrane cellulaire. Ici, la molécule en question est une molécule sanguine et les membranes sont celles de la barrière hémato-encéphalique. Les transporteurs sont spécialisés, dans le sens où ils ne laissent passer que quelques molécules bien précises et pas les autres. Par exemple, les transporteurs pour le glucose permettent uniquement le passage du glucose, mais pas des autres molécules. Quoi qu’il en soit, les molécules sanguines traversent la barrière hémato-encéphalique, du moins pour celles qui ont un transporteur adapté, mais elles n'arrivent pas directement dans les neurones ou dans le fluide entre les neurones. Rappelons que la barrière hémato-encéphalique est composée de deux couches : un vaisseau sanguin, entouré par des astrocytes. Les transporteurs localisés à la surface des vaisseaux sanguins cérébraux permettent aux molécules de passer dans les astrocytes, qui se chargent ensuite de les redistribuer aux neurones par divers mécanismes. Pour résumer, les molécules traversent la barrière hémato-encéphalique, se retrouvent dans les astrocytes, puis sont transférées aux neurones à la demande. Autant dire que les interactions entre neurones et astrocytes sont très importantes pour le métabolisme cérébral.
Rappelons que les astrocytes n'ont pas beaucoup de transporteurs différents, les principaux étant les transporteurs pour le glucose. L'absence de transporteurs pour de nombreuses molécules/ions empêche la majorité des ions et molécules de traverser les astrocytes et d'atteindre le cerveau. C'est pour cela que la barrière hémato-encéphalique est aussi sélective.
===Le métabolisme du glucose===
Les astrocytes ont un rôle très important dans le métabolisme énergétique des neurones. Pour simplifier, les astrocytes servent de réservoir d'énergie à disposition des neurones : ils captent le glucose sanguin, le mettent en réserve, et le distribuent aux neurones à la demande. Ils servent donc de réservoir d'énergie, dans lequel les neurones alentours peuvent puiser s'ils en ont besoin. À noter que les astrocytes stockent l'énergie non pas sous la forme de glucose, mais dans des molécules de glycogène et de lactate. Rappelons que le glycogène est synthétisé à partir du glucose. Le glycogène se fabrique en liant plusieurs molécules de glucose entre elles, d'une manière extrêmement compacte. C'est la forme sous laquelle les sucres sont stockés dans la plupart des cellules, cette molécule contenant une grande quantité d'énergie dans un volume très petit. Les astrocytes en concentrent de grandes quantités pour répondre aux besoins métaboliques des neurones, ainsi que pour leur fonctionnement. Si le besoin s'en fait sentir, les liaisons de la molécule de glycogène sont brisées par des enzymes, ce qui libère de nombreuses molécules de glucose, de lactate, ou d'autres formes de sucres.
Le transfert du glucose des astrocytes aux neurones peut se faire de diverses manières, mais il se fait principalement sous la forme de lactate. Les astrocytes libèrent du lactate dans le milieu extracellulaire, qui est capté par les neurones. Ce lactate est produit dans les astrocytes par dégradation du glucose et est destiné non au stockage, mais à la consommation immédiate. Dans le détail, le glucose est dégradé en pyruvate, qui est lui-même transformé en lactate. Le lactate est alors envoyé aux neurones, qui synthétisent du pyruvate avec, pyruvate qui est utilisé par la respiration aérobie (cycle de Krebs). On peut préciser que la libération du lactate par les astrocytes est couplée aux besoins des neurones par divers mécanismes. Ce qui veut dire que les astrocytes détectent que les neurones ont besoin d'énergie, et libèrent du lactate selon quand les besoins s'en font sentir. Le premier est que les neurones ont surtout besoin d'énergie, et donc de sucres, après avoir émis des potentiels d'action. Or, les astrocytes mesurent en permanence la quantité de neurotransmetteurs dans le milieu extra-cellulaire : plus il y en a, plus les neurones ont dépensés d'énergie pour les émettre et plus ils ont besoin d'énergie. Pour être plus précis, ils mesurent la quantité de glutamate, non de tous les neurotransmetteurs. Pour résumer, la liaison du glutamate sur les récepteurs astrocytaires va stimuler la libération du lactate dans le milieu extra-cellulaire, qui est ensuite assimilé par les neurones.
Évidemment, le métabolisme du glucose implique l'existence de transporteurs du glucose à la surface des astrocytes et des neurones. Dans l'ensemble des tissus, il existe quatorze transporteurs du glucose différents, qui n'ont pas la même composition chimique et sont de forme différente. Ils sont appelés GLUT-1, GLUT-2, GLUT-3, ..., GLUT-14, GLUT étant l'abréviation de ''GLUcose-Transporter''. Ils ne sont pas spécifiques du glucose, mais peuvent aussi servir de transporteurs à d'autres "sucres". Par exemple, le GLUT-5 sert de transporteur pour le glucose, mais aussi le fructose. D'autres servent de transporteur pour l'urate, le myoinositol et quelques autres molécules. Toujours est-il que l'on peut classer ces transporteurs du glucose en trois grandes catégories, mais seuls ceux de la première sont présents dans le cerveau, à savoir les récepteurs de GLUT-1 à GLUT-4 et le GLUT-14. Le transporteur GLUT1 est surtout présent dans la membrane des astrocytes, de même que le transporteur GLUT2. Le premier est présent dans tous les astrocytes cérébraux, alors que GLUT2 est présent dans certaines astrocytes, localisés dans des aires cérébrales bien précises (hypothalamus et tronc cérébral). Le transporteur GLUT3 est la chasse gardée des neurones.
===Le métabolisme du cholestérol===
Le cholestérol est un lipide intervenant dans de nombreuses fonctions métaboliques du corps, et joue notamment un rôle dans le fonctionnement du cerveau. Il s'agit en effet d'un des composants principaux de la myéline qui entoure les axones (la gaine de myéline). Sachant que plus de la moitié du cerveau est composée de substance blanche, donc de myéline, il n'est pas étonnant que le cerveau soit l'organe le plus riche de tous en cholestérol. Certaines études estiment que près de 20% du cholestérol total est localisé dans le cerveau. De plus, le cholestérol est un composant de la membrane des neurones et des cellules gliales. Plus les axones et dendrites d'un neurone se développent, plus ceux-ci doivent fabriquer de membrane cellulaire pour donner naissance aux axones et dendrites. Pour résumer, les besoins en cholestérol augmentent avec l'apparition de nouvelles synapses et dendrites.
La quantité de cholestérol cérébral reste approximativement constante au cours du temps. Ce n'est que lors de myélinisation du système nerveux que le cholestérol est fortement synthétisé. Lors de ces périodes, la formation des gaines de myéline augmente les besoins en cholestérol, qui ne peuvent être assouvis que par une synthèse importante. Les périodes de développement des synapses/neurones entrainent une forte consommation en cholestérol, les besoins étant alors fortement augmentés. En-dehors de ces périodes où les besoins sont augmentés, la synthèse de cholestérol cérébral est contrebalancée par sa consommation et son élimination. Le métabolisme cérébral du cholestérol est illustré dans le schéma ci-dessous, que je vous invite à regarder avant de lire ce qui va suivre.
[[File:Cerebrosterol.jpg|centre|vignette|upright=2|Métabolisme cérébral du cholestérol.]]
Le cholestérol sanguin n'est pas la source de cholestérol cérébral, vu que le cholestérol sanguin ne traverse pas la barrière hémato-encéphalique. Le cholestérol cérébral est synthétisé par les astrocytes, encore eux, sous la forme d'apolipoprotéines (ApoE). Ces apolipoprotéines sont synthétisées sous une forme immature, avant d'être finalisées par l'action de protéines membranaires de type ABCA. Elles sont ensuite absorbées par les neurones et sont transformées en cholestérol à l'intérieur des neurones, via l'action d'une enzyme.
Le cholestérol des neurones va ensuite être utilisé par le neurone. Il servira notamment dans la fabrication de la membrane du neurone. Rappelons que le neurone ne produit pas sa gaine de myéline, mais que ce sont les oligodendrocytes qui le font, ce qui fait que le choléstérol capté par les neurones ne sert pas à fabriquer la gaine de myéline. Mais l'utilisation principale du cholestérol intra-neuronal est de loin la formation de protéines appelées bêta-amyloides, présentes à la surface des cellules. On peut signaler que cette protéine est impliquée dans la maladie d'Alzheimer, bien que l'on sache mal comment... Rappelons que le cholestérol cérébral est aussi impliqué dans la fabrication de neurostéroïdes, des stéroïdes qui modulent l'action du GABA et du glutamate. Leur fabrication demande une conversion du cholestérol en prégnénolone, qui est elle-même transformée en divers stéroïdes, puis en neurostéroïdes. La conversion du cholestérol en prégnénolone est effectuée par l'enzyme CYP450scc et a lieu dans les mitochondries. Pour cela, le cholestérol est capté un transporteur qui capte le cholestérol cytoplasmique et le fait rentrer dans les mitochondries, à l'intérieur desquelles il s'effectue la transformation.
Il arrive que le cerveau produise un peu plus de cholestérol que demandé. Dans ce cas, le cholestérol produit en excès (non-utilisé par le cerveau) est soit stocké, soit éliminépar diverses voies métaboliques. Seul 1% du cholestérol cérébral est stocké dans les neurones et astrocytes. Il y prend alors la forme de gouttelettes de graisses de petite taille. Ce stockage demande que le cholestérol subisse des modifications chimiques, induites par une enzyme appelée cholesterol acyltransferase 1 (ACAT1/SOAT1).
Les neurones qui contiennent l'enzyme CYP46 peuvent dégrader le cholestérol en excès en une molécule appelée cérébrostérol. Appellation bien plus facile à retenir que le nom scientifique du cérébrostérol, à savoir : 24S-hydroxycholestérol. Ce cérébrostérol est émis dans le milieu-extracellulaire où il est soit éliminé, soit recyclé par les astrocytes. Le cérébrostérol peut traverser la barrière hémato-encéphalique, ce qui lui permet d'être éliminé dans la circulation sanguine. Plus de 2/3 du cérébrostérol est éliminé, le reste étant capté par les astrocytes et recyclé en ApoE.
===Le métabolisme des neurotransmetteurs===
Les astrocytes ne vont pas seulement fournir des nutriments aux neurones : ils vont aussi jouer un grand rôle dans le recyclage de certains neurotransmetteurs, comme le GABA, le glutamate, la glycine, et autres. Dans les faits, ces neurotransmetteurs sont produits à partir respectivement de sérine (glycine) et de glutamine (glutamate et GABA).
Prenons le cas de la sérine et de la glycine comme premier exemple. La sérine est produite dans les astrocytes à partir du glucose, avant d'être envoyé aux neurones. Les neurones vont alors synthétiser de la glycine à partir de cette sérine et l'utiliser comme neurotransmetteur. Après utilisation, la glycine est recapturée par les astrocytes et est alors dégradée en sérine. Et un nouveau cycle recommence : la sérine synthétisée peut alors être utilisée renvoyée aux neurones, et ainsi de suite.
Il en est de même pour le glutamate et le GABA. Ces deux neurotransmetteurs sont recapturés par les astrocytes, qui les dégradent en glutamine. Cette glutamine est alors renvoyée aux neurones, qui pourront re-synthétiser du glutamate et/ou du GABA et les utiliser comme neurotransmetteurs. Il faut noter que le glutamate peut aussi être synthétisé par les astrocytes et les neurones non pas à partir de glutamine, mais à partir d'une molécule produite par le cycle de Krebs (respiration aérobie).
[[File:Major metabolic fluxes in neuron-astrocyte coupling for resting conditions.png|centre|vignette|upright=3.0|Major metabolic fluxes (μmol/g tissue/min) in neuron-astrocyte coupling for resting conditions. The fluxes were calculated with the objective of maximizing the glutamate/glutamine/GABA cycle fluxes between the two cell types with subsequent minimization of Euclidean norm of fluxes, using the uptake rates given in Table 1 as constraints. Thick arrows show uptake and release reactions. Dashed arrows indicate shuttling of metabolites between the two cell types. Only key pathway fluxes are represented here for simplicity. The flux distributions for all the reactions listed in Additional File 1 are given in Additional File 4:Supplementary Table 3. (Description is figure caption from original article).]]
==Les vitamines et le cerveau==
Dans les sections précédentes du chapitre, nous avons surtout parlé de l'alimentation en énergie du cerveau. Mais il ne faut pas oublier que de nombreuses réactions chimiques permettent d'utiliser cette énergie et de la stocker. De plus, certaines réactions métaboliques permettent de synthétiser des neurotransmetteurs, de façonner la paroi des neurones, de contrôler les différences de concentrations en ions dans le neurone, et ainsi de suite. Ces réactions font naturellement appel à de nombreuses enzymes et autres protéines, la plupart étant synthétisées à partir des nutriments, mais aussi à partir de vitamines. Le cerveau est de loin un des plus gros consommateur de vitamines du corps.
D'ailleurs, il faut signaler que le cerveau est naturellement riche en vitamines, celui-ci ayant des réserves assez importantes. C'est notamment le cas pour les vitamines B, qui sont séquestrées dans le cerveau. Aussi, il n'est pas étonnant que leur teneur cérébrale soit largement supérieure à leur concentration sanguine. Par exemple, la concentration en vitamine B9 est près de 4 fois supérieure à la concentration sanguine, de même que la concentration en vitamine B5 et B8 est près de 50 fois plus importante dans le cerveau que dans le sang. Grâce à cela, le cerveau peut survivre à une déficience assez légère et/ou temporaire en vitamines. Cependant, cela n’empêche pas des déficiences en vitamines dans des situations extrêmes, comme un alcoolisme chronique ou un jeune prolongé. Ces déficiences peuvent avoir des conséquences neuropsychiatriques assez importantes, comme nous allons le voir maintenant. Dans ce qui va suivre, nous allons étudier les quatre vitamines les plus importantes pour le cerveau, à savoir les vitamines B1, B6, B9 et B12.
===Les vitamines B9 et B12===
Les vitamines B9 et B12 fonctionnent en tandem dans le corps, un manque de vitamine B12 pouvant être masqué par une complémentation de B9 et réciproquement. Ces cas sont cependant rares, vu que la carence en vitamine B12 ou B9 est exceptionnelle de nos jours. Elle ne touche que les végétariens qui ne se complémentent pas en vitamine B12 (et encore, après quelques mois ou années de ce régime sans complémentation) ou les personnes ayant des problèmes d'absorption des vitamines (anémie de Biermer, usage d'antiacides prolongé, problèmes intestinaux…). La récupération est souvent totale après complémentation en B12 ou B9, mais quelques patients peuvent avoir des séquelles s'ils ne sont pas pris en charge rapidement.
La vitamine B9, aussi appelée acide folique, a un métabolisme assez compliqué et est impliquée dans la réplication de l'ADN ou le métabolisme de certains acides aminés. La voie métabolique qui nous intéresse est cependant assez simple : l'acide folique est absorbé par les intestins, puis est transformé par le foie en acide lévoméfolique (5-méthyl-THF), la forme de vitamine B9 qui est absorbée par le cerveau. Ce métabolite peut traverser la barrière hémato-encéphalique en passant à travers un récepteur spécifique : le récepteur folate-alpha. Des déficits au niveau de ce récepteur peuvent entraîner une '''déficience cérébrale en folate''' dans laquelle seul le cerveau subit un déficit en B9, alors que le reste du corps a une alimentation vitaminique normale. Les symptômes sont une hypotonie, des crises épileptiques et un retard mental/psychomoteur. Sa cause principale est une maladie génétique très rare, liée à une mutation du gène FOLR1, le gène qui code pour le récepteur cérébral du folate. Une autre cause est la présence dans le sang d'anticorps qui visent ce récepteur, ce qui est une maladie auto-immune extrêmement rare.
La carence en vitamine B12 entraîne un '''syndrome neuro-anémique''', dont le nom traduit ses symptômes : une anémie et des symptômes neuropsychiatriques variés. Pour ce qui est des manifestations psychiatriques, les symptômes vont de symptômes mineurs (troubles du sommeil, dépression) à des symptômes plus graves pouvant aller jusqu’à la démence ou des crises psychotiques. Le symptôme neurologique le plus grave est une démyélinisation du système nerveux dont les symptômes sont semblables à la sclérose en plaque, qui peut toucher la moelle épinière, les nerfs et/ou le cerveau. L'atteinte des nerfs périphériques fait que ceux-ci perdent leur gaine de myéline, donnant des polynévrites assez marquées. L'atteinte médullaire touche essentiellement le faisceau pyramidal ainsi que les cordons antérieurs (système des colonnes dorsales). L’atteinte du faisceau pyramidal et des nerfs moteurs se traduit par l'apparition de troubles moteurs tels qu’une paralysie, une spasticité, un défaut de coordination motrice ou autre. L'atteinte des nerfs sensitifs et des colonnes dorsales médullaires est la cause de troubles sensitifs variés : douleurs dans les extrémités, picotements, sensations de décharges électriques, et cætera.
===La vitamine B6===
Nous avons vu il y a quelques chapitres que la vitamine B6 est primordiale dans la synthèse de la sérotonine et de la dopamine, du GABA et de la mélatonine. Pour rappel, la vitamine B6 est une coenzyme associée à plusieurs enzymes. Elle active l'enzyme ''dopa-décarboxylase'', qui dégrade le 5-HT est transformé en sérotonine et la L-DOPA en dopamine. C'est aussi la coenzyme de la GAD, qui dégrade le glutamate en GABA.
La vitamine B6 existe sous plusieurs formes différentes, mais seules trois d'entre elles entrent dans le cerveau : le pyridoxal, la pyridoxine et la pyridoxamine. Ce sont des formes inactives, qui demandent à être phosphatées pour devenir des formes actives de la vitamine B6, à savoir du phosphate de pyridoxal, du phosphate de pyridoxine ou du phosphate de pyridoxamine. Dans le cerveau, les formes inactives de B6 sont transformées en phosphate de pyridoxal par deux enzymes : la PK et la PNPO (''Pyridoxine 5'-phosphate oxidase''). Le phosphate de pyridoxal intervient ensuite dans la dégradation de la lysine et de la proline (deux acides aminés), via deux voies métaboliques différentes dans laquelle de nombreuses enzymes interviennent. Dans ce réseau métabolique, divers troubles peuvent survenir.
La plus fréquente, la ''carence en vitamine B6'' perturbe le fonctionnement global du cerveau. Une déficience en vitamine B6 entraine donc une baisse de production des neurotransmetteurs cités plus haut, qui touche préférentiellement la synthèse du GABA et de la sérotonine. La baisse de GABA induite se traduit par une hausse de l'activité électrique des neurones, avec deux conséquences principales : un mauvais sommeil et, plus rarement, des crises épileptiques. La baisse de sérotonine et de dopamine se manifeste quant à elle par un état anxio-dépressif et une hausse de l'impulsivité et de la nervosité. La déficience peut aussi se manifester dans le système nerveux périphérique par une inflammation généralisée des nerfs (une polynévrite). À l'inverse, une surdose de vitamine B6 n'entraine pas de symptômes clairs, tant que la surdose n'est pas prolongée. Cependant, une complémentation en B6 prolongée durant plusieurs mois peut engendrer des névrites totalement réversibles avec l'arrêt de la supplémentation.
L''''épilepsie sensible au phosphate de pyridoxal''' est une maladie génétique à transmission autosomique dominante. Elle est causée par une ''déficience en PNPO'', qui touche la synthèse du phosphate de pyrixodal à partir des formes inactives de B6. Avec cette maladie, l'enzyme PNPO n'est pas synthétisée, ce qui fait que la vitamine B6 n'agit plus du tout dans le cerveau. Cela entraine une carence massive en B6 active dans le cerveau. En conséquence, la synthèse des neurotransmetteurs est perturbée et le métabolisme neuronal des acides aminés est perturbé. Le résultat est une encéphalopathie épileptique néonatale, qui survient dès les premières heures de vie du bébé atteint. Elle ne réagit pas aux traitements habituels et n'est soignée que par un traitement à base de phosphate de pyridoxine/pyridoxal. D'autres symptômes peuvent survenir, comme une hypotonie, des troubles respiratoires, des mouvements anormaux, etc.
Similaire à la maladie précédente, l''''épilepsie pyridoxine-dépendante''' est un ensemble de maladies génétiques caractérisées par une encéphalopathie néonatale similaire à la maladie précédente. Les crises épileptiques sont de type myocloniques, avec un tracé typique sur l'EEG. La seule différence est que la maladie est sensible à un traitement à base de pyridoxine, une forme inactive de la B6, là où la précédente a besoin de la forme active de la B6. L'identification de cette maladie est souvent difficile à faire et le diagnostic se fait après administration de vitamine B6 ou un diagnostic génétique. Elle se manifeste très tôt : dans les premiers jours ou mois de vie pour les formes précoces, vers 1 à 3 ans pour les formes tardives. Ce syndrome touche entre une naissance sur 500 000 et une naissance sur 400 000. Ses causes sont nombreuses et de nombreux mutations génétiques peuvent la causer. Dans les grandes lignes, il existe deux sous-syndromes principaux : une forme précoce qui apparait dans les premières heures de vie, et une forme tardive qui apparait vers 1 à 3 ans.
* La forme précoce exprime une épilepsie prénatale dès 20 semaines de gestation. L'épilepsie se manifeste rapidement et toutes les formes de crises épileptiques peuvent survenir : myoclonies, absences, crises toniques, toniques-cloniques. Elle est souvent secondée par de l'irritabilité et une réaction excessive aux stimuli, ainsi que par des malformations cérébrales diverses (hydrocéphalie, malformations du corps calleux, ...). On peut observer des troubles métaboliques généraux, des troubles respiratoires, et bien d'autres symptômes divers. Sans traitements, la maladie entraine un retard mental et divers déficits neurologique et développementaux. La forme précoce la plus fréquente est causée par une mutation qui perturbe la synthèse de l'antiquitine, une enzyme de la voie de dégradation de la lysine.
* Par contraste, la forme tardive apparait avant 3 ans et répond initialement aux médicaments anti-épileptiques, avant que ces traitements cessent de faire effet. L'épilepsie est isolée, avec peu d'autres symptômes neurologiques et une absence de malformations cérébrales.
===La vitamine B1===
La vitamine B1, aussi appelée thiamine, est une vitamine du fameux cycle de Krebs (une série de réactions chimiques qui fournit de l'énergie aux cellules vivantes). Une carence en vitamine B1 entraine une pénurie d'énergie et d'ATP dans les cellules, qui se mettent à dysfonctionner et parfois à mourir. Le système nerveux étant un tissu très gourmand d'un point de vue métabolique, toute carence en vitamine B1 retenti en premier lieu sur le fonctionnement cérébral. Autant dire qu'elle est extrêmement importante pour le système nerveux, des carences pouvant être tout aussi graves que les carences en vitamines B12 ou B6. La carence en thiamine cause un stress métabolique aux neurones, qui cause des dysfonctionnements divers des potentiels d'action, mais qui peut aussi entrainer souvent la mort du neurone par apoptose. De plus, la carence va aussi modifier la recapture du glutamate, entrainant l'apparition d'une excitotoxicité (le glutamate excite les neurones à mort). Beaucoup de neurones dysfonctionnent, quand ils ne meurent rapidement, ce qui cause l'apparition d'une maladie : l''''encéphalopathie de Wernicke'''.
Ses symptômes sont souvent assez clairs, 80% des cas manifestant la triade : défaut de coordination des mouvements (ataxie), paralysie des yeux (ophtalmoplégie) et confusion (delirium). D'autres symptômes peuvent se faire jour, comme une profonde amnésie, une perte de la mémoire à court-terme, une psychose, ou des troubles végétatifs. L'origine de ces symptômes est liée à diverses atteintes du thalamus, de l'hypothalamus et du tronc cérébral. Par exemple, la paralysie oculaire provient d'une atteinte des noyaux des nerfs crâniens oculomoteurs. L"amnésie est quant à elle liée à une atteinte des corps mamillaires de l'hypothalamus. Une carence prolongée en B1 s'observe surtout chez les alcooliques, l'alcool stoppant l'absorption intestinale de la B1.
Chez certains patients, l'encéphalopathie de Wernicke évolue vers des troubles cognitifs permanents. La démence qui en résulte est appelée le '''syndrome de Korsakoff''', du nom de son découvreur. Son symptôme principal est une amnésie assez particulière. Nous allons devoir parler rapidement de l'amnésie, chose qui sera détaillé dans le chapitre sur la mémoire. L'amnésie du syndrome de Krosakoff est une perte de la mémoire à court-terme, avec une incapacité à mémoriser de nouveaux souvenirs ou de nouvelles connaissances. Les savoirs et souvenirs déjà acquis sont relativement préservés, bien que quelques déficits peuvent se voir. L'amnésie porte donc essentiellement sur les acquisitions qui suivent le traumatisme, cette forme d'amnésie étant appelée amnésie antérograde. L'amnésie qui touche les souvenirs d'avant l'apparition du syndrome est appelée amnésie rétrograde. L'amnésie du syndrome de Korsakoff est essentiellement antérograde, bien qu'une faible amnésie rétrograde soit possible. Si amnésie rétrograde il y a, celle-ci ne touche généralement que les souvenirs et savoirs récents, qui datent de quelques mois, années ou décennies avant le syndrome. Outre l'amnésie, divers troubles cognitifs peuvent se manifester, que ce soit des troubles du langage (aphasie), des troubles de la reconnaissance des objets et de la catégorisation (agnosie) ou des troubles intellectuels.
==Les minéraux et le cerveau==
Outre les vitamines, les mal-nommés « minéraux » sont d'une importance capitale pour le fonctionnement normal du cerveau. Les « minéraux » les plus importants sont de loin les ions Calcium, Potassium et Sodium, sans lesquels il ne peut y avoir de potentiels d'action. Leur rôle a déjà été abordé dans le chapitre sur potentiel d'action et nous n'en reparlerons pas ici. Les autres ions que nous allons aborder sont le magnésium, le cuivre, le fer et quelques autres. Vous remarquerez qu'il s'agit d'ions métalliques, à quelques exceptions près.
Tous les ions ont une concentration particulièrement bien régulée, le cerveau disposant de toute une machinerie chimique pour régler leur concentration. Il faut dire que ces ions deviennent toxiques quand ils sont en grandes quantités. C'est notamment le cas des ions métalliques, qui sont impliqués dans des réactions d’oxydoréduction qui créent des molécules « poisons » (des radicaux libres). En temps normal, les produits nocifs de ces réactions sont éliminés ou dégradés par la machinerie cellulaire du cerveau, mais ces processus sont dépassés quand la concentration en ions métalliques devient trop importante. De même, une déficience est nuisible pour les neurones, ces ions servant dans de nombreuses réactions chimiques importantes pour le fonctionnement des neurones.
La concentration en ions du cerveau est sévèrement contrôlée par divers mécanismes, dans lesquels la barrière hémato-encéphalique joue un rôle crucial. Elle protège le cerveau des variations de concentration ionique du sang. Par exemple, prenons le cas où la concentration en sodium du sang augmente, suite à la consommation d'un aliment particulier, d'une réponse hormonale, ou d'un supplément alimentaire. Le cerveau ne va pas être impacté par cette variation, et en sera isolé : la concentration ionique intracérébrale restera la même. C'est grâce à la barrière hémato-encéphalique, qui isole le cerveau des vaisseaux sanguins. L'absorption d'ions dans le cerveau, ou leur excrétion, est réalisée par des canaux ioniques et/ou des transporteurs, qui se trouvent à la surface de la barrière hématoencéphalique. Elles vont se lier aux minéraux ou aux vitamines à absorber et vont les rapatrier dans les astrocytes pour l'absorption. Une partie des ions est perdue dans le liquide céphalorachidien, et est emportée avec lui lors de son excrétion dans le sang.
===Le magnésium===
L'ion magnésium a aussi été vu dans le chapitre sur les récepteurs synaptiques et la plasticité synaptique. Nous avons vu que les récepteurs NMDA du glutamate sont bloqués par un ion magnésium, qui bouche le canal ionique. Du moins, c'est le cas tant que le potentiel du neurone reste inférieur à -60 mV. Si la tension dépasse ce seuil, l'ion Magnésium est éjecté et le canal ionique s'ouvre, excitant le neurone. On devine donc qu'un manque en magnésium se traduit par une hyper-excitabilité neuronale, alors qu'un excès de Magnésium entraine une hypo-excitabilité neuronale. Dans les deux cas, l'atteinte neuronale est diffuse, mais ciblée sur les neurones sensibles au glutamate.
* La déficience en magnésium entraine des symptômes neurologiques qui vont d'une simple asthénie et/ou une faiblesse musculaire, à des symptômes plus sérieux comme des convulsions et plus rarement un coma.
* Pour un excès en magnésium, les symptômes neurologiques sont frustres pour les faibles hypermagnésémies, mais bien plus lourds dans les cas graves. À faible dose en excès, le magnésium entraine une simple léthargie et/ou asthénie. Pour un excès assez fort, elle surtout des symptômes neuromusculaires. Il a, à hautes doses, un effet sur la jonction neuromusculaire similaire à celui du curare. En clair, elle cause une décontraction musculaire, une diminution des réflexes, des paralysies (notamment respiratoires). Dans certains cas extrêmes, on observe une dilatation pupillaire liée à un blocage du système nerveux sympathique.
===Le cuivre===
Le cuivre traverse la barrière hémato-encéphalique par le biais de deux transporteurs nommés CRT1 et APH7A, et s'accumule dans le cerveau. S'il y a trop de cuivre dans le cerveau, l'excès de cuivre est éliminé dans le liquide cérébrospinal, puis dans le sang, via l'action des mêmes transporteurs.
[[File:Metabolisme cerebral du cuivre.png|centre|vignette|upright=2.5|Métabolisme cérébral du cuivre]]
Les mêmes transporteurs sont présents à la surface des neurones et des cellules gliales, ce qui leur permet d'absorber du cuivre s'il en ont besoin. Le cuivre n'a pas de rôle physiologique clair au niveau du cerveau, au moment où j'écris ces lignes. Tout ce que l'on sait est que des excès ou des carences entrainent de nombreux symptômes au niveau du foie, du cerveau et d'autres tissus/organes. Chez certaines personnes, les transporteurs du cuivre dysfonctionnent, ce qui fait que le cuivre ne rentre pas, ou au contraire s'accumule, dans les tissus.
La '''maladie de Menkes''' est causée par un dysfonctionnement du transporteur ATP7A, qui entraine une déficience en cuivre, qui touche notamment le cerveau. Une des conséquences est que le cuivre ne traverse plus la barrière hémato-encéphalique. Les neurones privés de cuivre meurent rapidement, la myéline ne se forme pas autour des axones. Les patients montrent un manque de tonus musculaire (hypotonie), de l'épilepsie et des symptômes neurologiques variés. On observe aussi un visage déformé, caractéristique de la maladie, une perte des cheveux, et quelques autres symptômes. Il s'agit d'une maladie grave qui apparait quelques mois après la naissance. Les enfants touchés ne dépassent pas l'âge de 3-5 ans.
Les maladies liées à l'accumulation de cuivre dans le cerveau se manifestent (entre autres) par des symptômes neurologiques assez francs. Le cuivre s'accumule de préférence dans les ganglions de la base, qui sont les premiers touchés par un excès de cuivre, ce qui entraine des symptômes caractéristiques : parkinsonisme, mouvements involontaires anormaux (chorée, tics, dyskinésies, autres), troubles psychiatriques variés, atteinte cognitive.
<noinclude>[[File:Kayser-Fleischer ring.jpg|vignette|Anneau de Kayser-Fleischer, caractéristique de la maladie de Wilson.]]</noinclude>
La '''maladie de Wilson''' est une maladie (en fait, un ensemble de maladies génétiques) qui entraine une accumulation de cuivre dans l'organisme. Les organes les plus touchés sont le foie et le cerveau. Seuls une moitié des patients manifestent ces symptômes neuropsychiatriques. Les autres symptômes sont des problèmes de foie et aux yeux, parfois au cœur et aux reins. Les patients ont presque tous des troubles au foie, notamment une cirrhose, qui peuvent cependant passer inaperçus quand leurs symptômes sont frustres (ictère, fatigue, saignements, hypertension dans la veine porte, ...). Chez certains patients, on observe un anneau brun sur le pourtour des iris et des yeux, quasi-pathognomonique de la maladie. Au niveau cérébral, l'accumulation de cuivre est toxique pour les neurones, qui meurent progressivement. Les symptômes de la maladie traduisent une atteinte des ganglions de la base, qui sont les aires cérébrales les plus touchées par l'accumulation de cuivre.
* Les symptômes neurologiques sont des problèmes d'équilibre (ataxie), un parkinsonisme, des troubles du tonus musculaire (dystonie). Par contre, les troubles sensoriels sont très rares, au point que leur présence signifie pour le médecin qu'il vaut mieux chercher un autre diagnostic. Plus rarement, une épilepsie peut survenir, mais ce n'est pas un symptôme diagnostic.
* Les symptômes psychiatriques sont assez variés, mais sont essentiellement des troubles du comportement : désinhibition, irritabilité extrême, impulsivité, etc. On observe aussi fréquemment des troubles l'humeur (dépression), de l'anxiété. On observe plus rarement une psychose (syndrome regroupant hallucinations, délires, désorganisation de la pensée et troubles du comportement). Il arrive que les patients subissent des troubles cognitifs/intellectuels, qui miment une démence : pertes de mémoire, des troubles de l'idéation (pensée ralentie), et autres.
===Le fer===
Le fer est utilisé par tous les tissus et toutes les cellules du corps humain. Il est présent dans l'hémoglobine des globules rouge, la molécule qui fixe l'oxygène et le CO2 sanguin. Les autres cellules en ont besoin pour fabriquer de l'énergie sous forme d'ATP. Pour être précis, il sert pour la respiration cellulaire, au niveau de la chaine de transport d'électrons des mitochondries (ce détail aura son importance pour l'ataxie de Friedrich). Et cela vaut aussi pour les neurones et cellules gliales. De plus, les neurones ont besoin de fer pour fabriquer certains neurotransmetteurs. Toutes les monoamines sont synthétisées par des enzymes dont le fer est une co-enzyme.
Dans le sang, le fer se lie à une molécule de transport appelée la '''transferrine'''. Elle capture le fer libre, le transporte dans le sang et le relâche au niveau des tissus qui en ont besoin. La transferrine est tellement efficace qu'il n'y a presque pas d'ions Fer isolés, dissous dans le sang. Pour rentrer dans le cerveau, la transferrine doit traverser la barrière-hématoencéphalique. Nous en avions déjà parlé dans le chapitre sur la barrière hémato-encéphalique, aussi nous ne reviendrons pas dessus. Toujours est-il que le fer rentre dans le cerveau sous trois formes : du fer lié à de la transferrine, des ions <math>Fe^{2+}</math> et des ions <math>Fe^{3+}</math>.
Une fois dans le cerveau, une partie du <math>Fe^{2+}</math> est immédiatement oxydé en <math>Fe^{3+}</math> par diverses enzymes appelés des ferroxydases (l'''Hephaestine'' étant la plus connue). Le fer <math>Fe^{3+}</math> ne tarde pas à se lier à de la transferrine intra-cérébrale. Car oui, il y a de la transferrine dans le cerveau. Une partie provient de la transferrine sanguine, une autre partie est produite localement dans les plexus choroïdes.
Précisons que la transferrine sanguine est saturée à seulement 30% de sa capacité de transport maximale, la transferrine cérébrale atteint presque 100%. Il s'agit pourtant de la même molécule, mais les différences chimiques entre le sang et le tissu cérébral font que... Une conséquence est la transferrine ne peut pas servir de tampon en cas d'excès en fer cérébral, vu qu'elle est déjà chargée au maximum, ce qui rend les neurones très sensibles à une surcharge en fer.
La répartition du Fer n'est pas uniforme dans le cerveau. Premièrement, le fer s'accumule de préférence dans les cellules gliales plutôt que dans les neurones, avec une préférence pour les oligodendrocytes que les astrocytes. Comparé aux neurones, il y a environ 5 fois plus de fer dans les oligodendrocytes et 3 fois plus dans les astrocytes. La raison est que la synthèse de la myéline, réalisée par les oligodendrocytes, demande pas mal de fer. Au-delà de ces différences cellulaires, il y a une répartition inégale au niveau anatomique. Le fer s'accumule préférentiellement dans les ganglions de la base et la substance noire (la ''substantia nigra pars compacta'', pour être précis). Et cela explique pourquoi certaines maladies entrainant un excès de fer dans le cerveau se traduisent par des syndromes parkinsonien, comme on va le voir à l'instant.
Les neurones ne peuvent pas faire de stocks de fer, car ils ne produisent pas de ferritine, une enzyme qui peut faire des réserves de fer. Par contre, les cellules gliales en sont capables. Elles produisent de la ferritine, ce qui leur permet de faire des réserves de fer. De même, la barrière hémato-encéphalique peut émettre des molécules de ferritine, qui sont captées par les oligodendrocytes. Ils peuvent ainsi internaliser la ferritine produite par la BHE si besoin. Les oligodendrocytes ont des stocks de ferritine plus important que ceux des astrocytes, vu qu'ils ont des besoins en fer plus important (métabvolisme + fabrication myéline).
Les astrocytes peuvent absorber le fer de deux manières. La première absorbe les ions <math>Fe^{2+}</math> via un canal ionique/transporteur nommé le DMT1. La seconde absorbe la transferrine cérébrale de la même manière que le fait la barrière hémato-encéphalique. La transferrine est absorbée, le fer s'en détache, puis est convertit en ion <math>Fe^{2+}</math>. Les astrocytes peuvent ensuite éliminer le fer en excès via un transporteur nommé la ferroportine. Les neurones font la même chose que les astrocytes, avec les mêmes canaux ioniques, les mêmes enzymes, la ferroportine en cas d'excès, etc.
La régulation du taux de fer sanguin est en partie le fait des astrocytes. Ils produisent une hormone appelée l'hepcidine, qui régule le taux de fer sanguin. L'hepcidine agit notamment sur la ferroportine, une molécule de transport du fer, qui permet au fer de rentrer dans le cerveau. L'hepcidine bloque ce transporteur, sans compter qu'il détruit les transporteurs "en trop" en les internalisant dans les cellules de la barrière hémato-encéphalique.
[[File:Iron BBB.jpg|centre|vignette|upright=2.5|Les différentes voies de traversée de la barrière hémato-encéphalique pour le fer. Le transport vésiculaire normal est illustré sur la cellule de droite, la voie utilisant la ferroportine est illustré sur la cellule du milieu.]]
Quelques rares maladies génétiques entrainent une accumulation de fer dans le cerveau. Elles sont regroupées sous le nom de '''''Neurodegeneration with brain iron accumulation''''', abrévié NBIA. L'accumulation touche en priorité les ganglions de la base et se traduit donc par un syndrome parkinsonien ou d'autres mouvements anormaux (chorée, dystonies), parfois couplés à un déclin intellectuel/cognitif. Les médecins ont identifié une petite dizaine de sous-types de NBIA, chacun se distinguant des autres par le gène muté et les mécanismes de l'accumulation du fer.
{|class="wikitable"
|-
!Type de NBIA
!Gène muté
|-
!Neurodégénération associée à la panthotenate-kinase (PKAN)
|PANK2
|-
!Neurodégénération associée à PLA2G6 (PLAN)
|PLA2G6
|-
!Neurodegeneration associée aux protéines de la membrane mitochondriale (MPAN)
|C19orf12
|-
!Neurodégénération associée à la protéine Beta-Propeller (BPAN)
|WDR45
|-
!FAHN
|FA2H
|-
!Syndrome de Kufor-Rakeb
|ATP13A2
|-
!Neuroferritinopathy
|FTL
|-
!Acéruloplasminémie
|CP
|-
!Syndrome de Woodhouse-Sakati
|DCAF17
|-
!CoPAN
|COASY
|}
: Pour ceux qui veulent en savoir plus sur les NBIA, je conseille la lecture de ce lien : [https://www.nbiadisorders.org/about-nbia/overview-of-nbia-disorders NBIA disorders association]
Mais il existe des maladies qui sont causées non pas par une accumulation de Fer dans le cerveau, mais par des dysfonctionnements plus compliqués à expliquer. L''''ataxie de Friedreich''' est une de ces maladies. Cette maladie est causée par une mutation qui perturbe la fabrication d'une enzyme, la frataxine. Le métabolisme intracellulaire du Fer est perturbé, et plus précisément le métabolisme mitochondrial. Le résultat est que les cellules des tissus fortement consommateurs d'ATP meurent rapidement, les neurones ne faisant pas exception. De plus, la gaine de myéline des neurones se dégrade et finit par disparaitre, sans être remplacée.
La maladie démarre aux alentours de l'adolescence et se manifeste principalement par des problèmes d'équilibre et de coordination des mouvements (une ataxie, qui donne son nom à la maladie). Par la suite, on observe des troubles neurologiques moteurs et sensoriels, assez divers, d'apparition progressive. Certains sont causés par une atteinte du cervelet (troubles de l’articulation, des mouvements oculaires), d'autres par une atteinte faisceau pyramidal (paralysie, faiblesse musculaire), et/ou par une atteinte de la moelle épinière.
Typiquement, le patient a du mal à marcher, perd facilement l'équilibre, a du mal à coordonner ses mouvements. Puis, le patient perd progressivement l'usage de ses bras et de ses jambes, il ressent une faiblesse musculaire envahissante. Parfois, il a du mal à articuler, ses mouvements oculaires sont erratiques. Quand sa moelle épinière et atteinte, sa sensibilité corporelle se dégrade, son sens du toucher et sa proprioception disparaissent.
Beaucoup plus rarement, les nerfs optiques et auditifs se démyélinisent, causant perte d'acuité visuelle ou auditive, cécité, surdité, etc. Précisons pour finir que la maladie ne touche pas que le cerveau, mais cause aussi des atteintes cardiaques (très fréquentes), une scoliose ou d'autres déformations osseuses, et parfois un diabète (10-20% des patients).
<noinclude>
{{NavChapitre | book=Neurosciences
| prev=La barrière hémato-encéphalique
| prevText=La barrière hémato-encéphalique
| next=La pression intracrânienne
| nextText=La pression intracrânienne
}}{{autocat}}
</noinclude>
nfmew6j7v43fv7bh3yghcl982g6yrg2
Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions
0
79799
744057
734092
2025-06-03T18:38:47Z
Mewtow
31375
/* La file d'instruction et le cache de macro-opération */
744057
wikitext
text/x-wiki
Les processeurs modernes disposent de plusieurs unités de calcul, de bancs de registres larges et de tout un tas d'optimisations permettent d’exécuter un grand nombre d'instructions par secondes. Les opérations de calcul, les accès mémoire : tout cela est très rapide. Mais rien de cela ne fonctionnerait si l'unité de chargement ne suivait pas le rythme. En soi, l'unité de chargement est simple : le ''program counter'', les circuits pour l'incrémenter et gérer les branchements, l'unité de prédiction de branchement, et de quoi communiquer avec le cache. On doit aussi ajouter le registre d'instruction. Difficile de trouver de quoi l'optimiser, à part rendre l'unité de prédiction plus efficace.
Pourtant, les processeurs incorporent diverses optimisations qui rendent le tout beaucoup plus rapide. La plupart de ces optimisations consistent à ajouter des files d'attente ou des mémoires caches dans le ''front-end'', que ce soit après l'étape de chargement ou de décodage. Les caches en question sont situés en aval du cache d'instruction, ce qui en fait des sortes de cache de niveau 0. Les optimisations incluent le préchargement d'instruction, l'usage de files d'attente pour découpler divers circuits et quelques autres. Voyons lesquelles dans ce chapitre.
==La file d'instruction et le cache de macro-opération==
L'unité de chargement contient de nombreux circuits fortement liés entre eux, et on peut découper le tout en plusieurs circuits. L'unité de calcul d'adresse émet les adresses des instructions à charger, qui sont consommées par le cache d'instruction, qui lui-même envoie les instructions lues dans le registre d'instruction ou la file d'instructions. L'unité de calcul d'adresse regroupe : l'unité de prédiction de branchement, le ''program counter'', le circuit pour incrémenter le ''program counter'', les MUX associés pour gérer les branchements.
Le couplage de ces structures fait qu'au moindre défaut de cache d'instruction, l'ensemble stoppe. Par exemple, l'unité de chargement stoppe en cas de défaut de cache. Même chose si jamais une instruction multicycle s’exécute dans le pipeline et bloque toutes les étapes précédentes. Pourtant, il est en théorie possible, et même utile, que certaines structures prennent de l'avance même si d'autres sont bloquées. Par exemple, si le pipeline est bloqué en aval de l'unité de chargement, l'unité de chargement peut en théorie précharger à l'avance des instructions. Ou encore, en cas de défaut de cache d'instruction, l'unité de calcul d'adresse peut précalculer les adresses destinées au cache et les mettre en attente. Pour cela, l'unité de chargement incorpore un paquet de mémoires FIFOs, que nous voir en détail dans ce qui suit.
===Les files d'instruction===
Les processeurs modernes intègrent une '''file d'instruction''', 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 précharger des instructions dans la file d’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 éxecute 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 macro-fusion===
La présence d'une file d'instruction et/ou d'une ''Prefetch Input Queue'' permet d'ajouter une optimisation très importante au processeur, qui ne seraient pas possibles sans elle. L'une d'entre elle est la '''macro-fusion''', une technique qui permet de fusionner une suite d'instructions consécutives en une seule micro-opération. Par exemple, il est possible de fusionner une multiplication suivie d'une addition en une seule instruction MAD (''multiply and add''), si les conditions adéquates sont réunies pour les opérandes. Comme autre exemple, il est possible de fusionner un calcul d'adresse suivi d'une lecture à l'adresse calculée en une seule micro-opération d'accès mémoire. Et enfin, il est possible de fusionner une instruction de test et une instruction de branchement en une seule micro-opération de comparaison-branchement. C'est surtout cette dernière qui est utilisée sur les processeurs Intel modernes.
La macro-fusion est effectuée pendant le décodage, en décodant des instructions dans le tampon d'instruction. Le décodeur reconnait que plusieurs instructions dans le tampon d'instruction peuvent être fusionnées et fournit en sortie la micro-opération équivalent. L'avantage de cette technique est que le chemin de données est utilisé plus efficacement. Notons que la macro-fusion diminue le nombre d'instructions à stocker dans le ROB et dans les différentes mémoires intégrées aux processeur, qui stocke les micro-opérations.
La technique est parfois couplée à un circuit de prédiction, qui détermine si une série d'instruction à fusionner va apparaitre sous peu. L'idée est que dans certains cas, le tampon d'instruction contient le début d'une suite d'instruction combinables. Suivant ce qui sera chargé après, la macro-fusion pourra se faire, ou non. Mais le processeur ne sait pas exactement quelles seront les instructions chargées juste après et il ne sait pas si la macro-fusion aura lieu. Dans ce cas, il peut utiliser un circuit de prédiction de macro-fusion, qui essaye de prédire si les instructions chargées sous peu autoriseront une macro-fusion ou non. Si c'est le cas, les instructions potentiellement fusionnables, le début de la série macro-fusionnable, est mise en attente dans le tampon d'instruction, en attendant les futures instructions.
===Le cache de macro-opérations===
Le cache de macro-opérations est un cache présent en aval de l'unité de chargement, à côté de la file d’instruction. Il mémorise les dernières instructions envoyées à l'unité de décodage, à savoir non pas les instructions préchargées, mais celles qui sont en cours de décodage ou d’exécution, celles qui ont quitté la file d'instruction. Il sert dans le cas où ces instructions sont ré-éxecutées, ce qui est souvent le cas avec des boucles de petite taille.
A chaque cycle d'horloge, ce cache est consulté, de manière à vérifier si l'instruction voulue est dans ce cache ou non. Cela évite un accès au cache d'instruction. Son implémentation est simple : il s'agit d'un petit cache adressé par le ''program counter''. Si l'instruction a été chargée il y a peu, l'instruction machine est mémorisée dans une ligne de cache, le tag de cette ligne n'est autre que son adresse, le ''program counter'' associé. L'accès au cache de macro-opérations est de un seul cycle, pas plus.
[[File:Cache de macro-ops.png|centre|vignette|upright=2|Cache de macro-ops]]
L'intérêt n'est pas évident, mais disons que l'accès à ce cache gaspille moins d'énergie qu’accéder au cache d'instruction. C'est là l'intérêt principal, même s'il se peut qu'on puisse avoir un gain en performance. Le gain en question vient du fait que l'accès est plus rapide dans ce cache, ce qui n'est le cas que dans des conditions précise : si le cache d'instruction est pipeliné et a un temps d'accès de plusieurs cycles.
==La file de micro-opérations et le cache de micro-opérations==
[[File:File d'instruction.png|vignette|upright=1|File d'instruction]]
Sur les processeurs modernes, la sortie du décodeur est reliée à une mémoire FIFO semblable à la file d'instruction, mais placée juste après le décodeur. 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''', par simplicité. 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 continuer à charger et décoder des instructions, et accumuler des instructions décodées dans la file de micro-opérations. En clair, la file de micro-opérations met en attente les instructions 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. Des instructions ne peuvent plus être chargée et 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 peut en profiter pour émettre les instructions en attente dans la file de micro-opérations. 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.
La file de micro-opération est souvent complétée par plusieurs circuits, dont un circuit de micro-fusion, un cache de micro-opérations et le ''loop stream detector''. Voyons ces circuits dans ce qui suit.
[[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 micro-fusion===
La présence d'une file de micro-opération permet d'effectuer une optimisation appelée la '''micro-fusion'''. Elle remplace deux micro-opérations simples consécutives en une seule micro-opération complexe équivalente. Par exemple, sur certains processeurs, le chemin de données est capable d'effectuer une lecture/écriture en adressage base+index en une seule micro-opération. Mais le jeu d'instruction est une architecture Load-store où le calcul d'adresse et l'accès mémoire se font en deux instructions séparées. Dans ce cas, la micro-fusion peut faire son travail et fusionner le calcul d'adresse avec l'accès mémoire.
C'est une optimisation compatible avec la macro-fusion, les deux se ressemblant beaucoup. On peut se demander pourquoi les deux existent ensemble. La raison est historique, et tient au design des processeurs x86. Les premiers processeurs x86 avaient un chemin de données simple, avec les décodeurs qui allaient avec. Sur de tels processeurs, le calcul d'adresse et l'accès mémoire étaient séparés en deux instructions. Puis, avec l'évolution de la technologie, le chemin de données est devenu capable de faire les deux en une seule micro-opération. Pour compenser cela, Intel et AMD auraient pu changer leurs décodeurs en profondeur. A la place, ils ont préféré utiliser la technique de la micro-fusion, par simplicité.
===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 passons). 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 conserve les N dernières instructions décodées dans la file d'instruction, qui se comporte alors comme une sorte de pseudo-cache FIFO. Un circuit annexe, appelé le ''Loop Stream Detector'' (LSD), détecte lesboucles dans la file de micro-opérations et optimise leur exécution. Avec un LSD, la file d'instruction 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. 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, à 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.
L'optimisation 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]]
Les CPU Intel modernes disposent d'un ''loop stream detector'', les CPU AMD en avaient sur les microarchitectures Zen 4 mais il a disparu sur la microarchitecture Zen 5. Quelques CPU ARM avaient aussi un ''loop stream detector'', notamment le Cortex A15. É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. 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. Pour donner quelques chiffres, les processeurs ARM Cortex A15 géraient des boucles de maximum 32 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.
Il faut noter que le ''loop stream detector'' a été désactivé par des mises à jour de microcode sur quelques architectures, comme sur la microarchitecture Zen 4 d'AMD ou les CPU de microarchitecture Skylake et Kaby Lake d'Intel. Pour la microarchitecture 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 décode les instructions en micro-opérations, qui sont accumulées dans le cache de micro-opérations. Les itérations suivantes de la boucle vont chercher les micro-opérations adéquates dans le cache de micro-opération : on n'a pas à décoder l'instruction une nouvelle fois.
Intuitivement, vous vous dites que son implémentation la plus simple mémorise les N dernières micro-opérations exécutées par le processeur, ce qui en fait un cache FIFO. Mais la réalité est que c'est déjà ce qui est fait par le couple LSD + file de micro-opération. 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. De plus, le cache de micro-opération est séparé de la file de micro-opération. Et il est alimenté non pas par l'unité de décodage, mais par la file de micro-opérations. Ce sont les micro-opérations qui quittent la file de micro-opérations qui sont insérées dans le cache, pas celles qui quittent directement le décodeur.
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.
La différence avec le cache de micro-opération est que la 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, avec un cache de macro/micro-opération, on pourra lire les instructions précédant le IF...ELSE dedans. Le cache de micro-opération est donc plus efficace que le ''Loop Stream Detector'', mais pour un cout en transistor plus élevé.
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.png|centre|vignette|upright=2|File de micro-opérations et cache de micro-ops]]
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 microarchitecture Skylake, chaque ligne de cache était associée à une instruction machine et pouvait contenir de 1 à 6 micro-opérations. La suite de micro-opérations correspondant à 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.
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. Le cache de micro-opération est une voie de chargement parallèle au ''front-end'' proprement dit. 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.
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 l'é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 émission d'instruction, pour limiter la consommation d'énergie an détriment des performances. Ces limitations ne sont pas présentes sur les architectures récentes.
Aussi bien le cache de macro-opérations que le cache de micro-opérations optimisent l'exécution des boucles, mais ils ne sont pas au même endroit dans le pipeline : avant et après l'unité de décodage. Et le premier mémorise des instructions machines, l'autre des micro-opérations décodées. Les avantages et inconvénients sont totalement différents. Niveau capacité des deux caches, l'encodage des instructions machines est plus compact que la ou les micro-instructions équivalente, ce qui est un avantage pour le cache de macro-opérations à capacité équivalente. Par contre, le cache de micro-opérations permet de désactiver les décodeurs en cas de succès de cache, vu que les instructions ne doivent plus être décodées et renommées. Le gain est d'autant plus important si les instructions ont un encodage complexe, ou si les instructions sont à longueur variable, ce qui rend leur décodage complexe et donc lent. Globalement, plus le décodage est complexe et/ou long, plus le cache de micro-opérations fait des merveilles.
==Le préchargement d'instructions et la ''Fetch Target Queue''==
Les processeurs modernes incorporent une optimisation assez intéressante : ils découplent l'unité de prédiction de branchement et le ''program counter'' de l'accès au cache d'instruction. Pour cela, ils incorporent une mémoire FIFO entre l'unité de prédiction de branchement et le cache d'instruction. 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/écrire 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 !
Un avantage de la FTQ tient dans le fait que les caches d'instructions sont pipelinés, sur le même modèle que les processeurs. On peut leur envoyer une demande de lecture/écriture par cycle, alors que chaque lecture/écriture prendra plusieurs cycles à s'effectuer. L'accès au cache d'instruction a donc une certaine latence, qui est partiellement masquée par la FTQ au point où elle ne s'exprime qu'en cas de défaut de cache assez important. Par exemple, si l'accès au cache d'instruction prend 4 cycles, une FTQ qui met en attente 4 adresses camouflera le temps d'accès au cache, tant qu'il n'y a pas de mauvaise prédiction de branchement. La FTQ est aussi très utile avec les unités de branchement modernes, qui peuvent mettre plusieurs cycles pour fournir une prédiction. Prendre de l'avance avec une FTQ amorti partiellement le temps de calcul des prédictions.
: Si le cache d'instruction est multiport et accepte plusieurs accès simultanés, il peut consommer plusieurs entrées dans la FTQ à la fois.
Mais l'avantage principal de la FTQ est qu'elle permet l'implémentation d'une optimisation très importante. Il y a quelques chapitres, nous avions parlé des techniques de '''préchargement d'instruction''', qui permettent de charger à l'avance des instructions dans le cache d'instruction. Nous avions volontairement laissé de côté le préchargement des instructions, pour tout un tas de raisons. Et la raison est justement que la prédiction de branchement et le préchargement des instructions sont fortement liés sur les processeurs modernes. Il est maintenant possible d'aborder le préchargement pour les instructions, d’où cette section.
Notons que par préchargement des instructions, on peut parler de deux formes de préchargement, fortement différentes. La première correspond au préchargement normal, à savoir le préchargement des instructions dans le cache d'instruction L1, à partir du cache L2. Il s'agit donc d'un préchargement dans le cache d'instruction. Mais il existe aussi une autre forme de préchargement, qui consiste à précharger à l'avance des instructions dans la file d'instruction et qui a été abordée dans la section sur la ''prefetch input queue''. Les deux formes de préchargement n'ont pas lieu au même endroit dans la hiérarchie mémoire : l'une précharge du cache L2 vers le L1i, l'autre du cache L1i vers la file d'instruction (ou dans le cache de macro-opération). Mais les algorithmes utilisés pour sont sensiblement les mêmes. Aussi, nous allons les voir en même temps. Pour faire la distinction, nous parlerons de préchargement L2-L1i pour la première, de préchargement interne pour l'autre.
===Les algorithmes de préchargement d'instructions===
Les techniques basiques de préchargement consistent à charger des instructions qui suivent la dernière ligne de cache accédée. Quand on charge des instructions dans le cache d’instruction, les instructions qui suivent sont chargées automatiquement, ligne de cache par ligne de cache. il s'agit due préchargement séquentiel, la technique la plus simple de préchargement, qui profite de la localité spatiale. Elle est utilisée pour précharger des instructions du cache L2 vers le cache L1i, mais aussi pour le préchargement interne dans la file d'instructions.
[[File:Branchements et préchargement séquentiel.png|centre|vignette|upright=2|Branchements et préchargement séquentiel.]]
Mais un ''prefetcher'' purement séquentiel gère mal les branchements. Si un branchement est pris, les instructions de destination ne sont pas chargées, si elles ne sont pas dans la ligne de cache suivante. Pour le préchargement L2-L1i, cela ne pose pas de problèmes majeurs, au-delà de la pollution du cache L1i par des instructions inutiles. Mais pour le préchargement interne, c'est autre chose. 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.
Il existe des techniques de préchargement plus élaborées qui marchent mieux en présence de branchements. Elles utilisent toutes une collaboration de l'unité de prédiction de branchement. Elles accèdent au ''Branch Target Buffer'', pour détecter les branchements, leur destination, etc. Le tout peut se coupler à la technique du prédécodage. Avec cette dernière, le prédécodage décode en partie les instructions lors de leur chargement dans le cache, et détecte les branchements et leur adresse de destination à ce moment-là. Ces informations sont alors mémorisées dans une table à part, ou dans le BTB. Mais la plupart des designs utilisent le BTB, par souci de simplicité. Il existe globalement deux à trois techniques principales, que nous allons voir dans ce qui suit.
La première technique prédit si le branchement est pris ou non, et agit différemment si le branchement est pris ou non. Si le branchement est pris, elle précharge les instructions à partir de l'adresse de destination des branchements pris. Sinon, elle précharge les instructions suivantes avec préchargement séquentiel. Il s'agit du '''''target line prefetching'''''
[[File:Target line prefetching.png|centre|vignette|upright=2|Target line prefetching.]]
Une autre technique ne prédit pas les branchements et précharge à la fois les instructions suivantes avec le ''next-line prefetching'', et la ligne de cache de destination du branchement avec le ''target line prefetching''. Comme ça, peu importe que le branchement soit pris ou non, les instructions adéquates seront préchargées quand même. On appelle cette technique le '''préchargement du mauvais chemin''' (''wrong path prefetching'').
[[File:Préchargement du mauvais chemin.png|centre|vignette|upright=2|Préchargement du mauvais chemin.]]
Le ''target line prefetching'' est plus complexe à implémenter, car il demande de prédire les branchements. Mais elle a l'avantage de ne pas précharger inutilement deux lignes de cache par branchement, seulement une seule. Par contre, le préchargement est inutile en cas de mauvaise prédiction de branchement : non seulement on a préchargé une ligne de cache inutilement, mais en plus, la ligne de cache adéquate n'a pas été chargée. On n'a pas ce problème avec le préchargement du mauvais chemin, qui garantit que la ligne de cache adéquate est toujours préchargée.
===L'implémentation du préchargement interne, dans la file d'instruction===
Le préchargement dans la file d'instruction est généralement de type séquentiel, mais certains processeurs font autrement. Déjà, il faut remarquer que le ''target line prefetching'' correspond en réalité à la prédiction de branchement classique. L'adresse de destination est prédite, et on charge les instructions adéquates dans la file d'instruction. La prédiction de branchement, associée à une file d'instruction, est donc une forme de préchargement. Il fallait y penser. Enfin, des processeurs assez rares utilisaient le 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 dans laquelle on utilise la prédiction de branchement pour faire du ''target line prefetching''. Une fois que l'on sait si la prédiction de branchement était correcte, on est certain qu'une des deux files contiendra les instructions valides. Le contenu de la file adéquate est conservé, alors que l'autre est intégralement invalidée. Le choix de la bonne file se fait avec un multiplexeur. C'est approximativement la technique qui était implémentée sur le processeur de mainframe IBM 370/165, par exemple, et sur quelques modèles IBM similaires.
Le problème est que cette méthode demande de charger deux instructions à chaque cycle. Cela demande donc d'utiliser un cache d'instruction multiport, avec un port par file d'instruction. Le cout en circuit d'un cache double port n'est pas négligeable. Et le gain en performance est assez faible. Le préchargement dans la file d’instruction permet d'économiser quelques cycles lors de l'accès au cache d'instruction, guère plus. Le gain est maximal lorsque les instructions préchargées ont généré un défaut de cache, qui a rapatrié les instructions adéquates pendant que le processeur exécutait les mauvaises instructions, avant que la mauvaise prédiction de branchement soit détectée. Dans ce cas, le défaut de cache a eu lieu pendant la mauvaise prédiction et sa réparation, et non après.
====La gestion des branchements successifs====
Un autre défaut de cette méthode est la présence de branchements successifs. Par exemple, si jamais on rencontre un branchement, le flux d'instructions se scinde en deux : 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 méthode la plus simple stoppe le chargement du flux en attendant que le premier branchement soit terminé. Cette solution est intuitive, mais est celle où on a les gains en performance les plus faibles. Elle est couramment implémentée d'une manière assez particulière, qui ne correspond pas tout à fait à un stop du chargement, mais qui utilise les lignes de cache. L'unité de préchargement est conçue pour copier des lignes de cache entières dans la file d'instruction. Le processeur (pré-)charge deux lignes de cache : celle du bon chemin, celle du mauvais chemin. Il les précharge dans deux files d'instructions, qui contiennent généralement une ligne de cache grand maximum. Le temps que l'on ait chargé les deux files d'instruction, le résultat du branchement est connu et on sait laquelle est la bonne.
L'autre possibilité est d'utiliser la prédiction de branchement pour ce flux, afin de poursuivre le chargement de manière spéculative. Elle donne de bonnes performances, mais demande des unités de prédiction de branchement spéciales, dans le cas où les deux flux tombent sur un branchement en même temps. Cette technique est indirectement liée au cache de traces que nous verrons dans le chapitre sur les processeurs superscalaires. Nous n'en parlons pas ici, car ce genre de techniques est plus liée aux processeurs superscalaires qu'un processeur avec un pipeline normal.
Une autre possibilité consiste à scinder ce flux en deux et charger les deux sous-flux. 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.
[[File:Exécution stricte 01.png|centre|vignette|upright=2|Exécution stricte, seconde.]]
====Les processeurs à exécution de chemins multiples====
L'idée précédente peut en théorie être améliorée, afin de non seulement charger les instructions en provenance des deux chemins (celui du branchement pris, et celui du branchement non pris), mais aussi de les exécuter : c'est ce qu'on appelle l''''exécution stricte''' (''eager execution''). Bien sûr, on n’est pas limité à un seul branchement, mais on peut poursuivre un peu plus loin.
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 destinés à la 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. Il y a le même problème avec le préchargement interne simple, quand on utilise le préchargement du mauvais chemin, comme vu juste au-dessus.
===L'implémentation matérielle du préchargement de cache L2-L1i===
Pour comprendre comment s'effectue le préchargement L2-L1i, il faut regarder comment l'unité de chargement communique avec les caches. L'unité de prédiction de branchement est généralement regroupée avec le ''program counter'' et les circuits associés (les incrémenteurs/MUX associés), pour former l'unité de chargement proprement dite. L'unité de chargement émet des adresses consommées par le cache d'instruction, qui lui-même envoie les instructions lues dans le registre d'instruction ou la file d'instructions.
Le couplage de ces structures fait qu'au moindre défaut de cache d'instruction, l'ensemble stoppe. Et notamment, l'unité de prédiction de branchement stoppe en cas de défaut de cache. Même chose si jamais une instruction multicycle s’exécute dans le pipeline et bloque toutes les étapes précédentes. Les pertes de performance ne sont pas très importantes, mais elles existent. Et le préchargement se manifeste dans ces situations.
Le préchargement d'instructions consiste à découpler ces structures de manière à ce qu'elles fonctionnent plus ou moins indépendamment. Le but est qu'en plus des accès normaux au cache d'instruction, l'unité de chargement envoie des informations au cache L2 ou L1i en avance, pour effectuer le préchargement. L'unité de chargement doit alors prendre de l'avance sur le cache, pour effectuer les accès au cache L2 en avance, tout en maintenant l'état normal pour effectuer les accès normaux. C'est donc plus ou moins l'unité de chargement qui s'occupe du préchargement, ou du moins les deux sont très liées.
====L'anticipation du ''program counter''====
Avec la solution la plus simple, on a une unité de chargement qui s'occupe des accès au cache d'instruction, et une unité de préchargement qui prend de l'avance sur l'unité de chargement, et communique avec le cache L2. La technique la plus basique se base sur un ''Lookahead program counter'', un second ''program counter'' qui ne fonctionne que lors d'un défaut de cache d'instruction. Il est initialisé avec le ''program counter'' lors d'un défaut de cache, puis il est incrémenté à chaque cycle et les branchements sont prédits, ce qui fait qu'il est mis à jour comme si l’exécution du programme se poursuivait, alors que le reste du processeur est mis en attente.
La technique initiale utilisait ce second ''program counter'' pour accéder à une table de prédiction, qui associe à chaque valeur du ''program counter'', l'adresse des données chargées par l'instruction associée. Les adresses fournies à chaque cycle par cette table sont alors envoyées aux unités de préchargement pour qu'elles fassent leur travail. La technique permettait donc de précharger des données en cas de défaut de cache, mais pas d'instructions. Il ne s'agissait pas d'une technique de préchargement des instructions, mais de préchargement de données.
La technique a ensuite été adaptée pour le chargement des instructions par Chen, Lee et Mudge. Leur idée utilisait deux unités de prédiction de branchements : une couplée à l'unité de chargement, l'autre pour le préchargement. La première utilisait le ''program counter'' normal, l'autre se déclenchait en cas de défaut de cache et utilisait un ''lookahead program counter''. Les adresses générées par le ''lookahead program counter'' étaient envoyée au cache d'instruction, sur un port de lecture séparé. La ligne de cache lue était alors prédécodée pour détecter les branchements, qui étaient prédits, et rebelote. Il est possible d'adapter la méthode pour que les adresses soient accumulées dans une mémoire FIFO, et étaient consommée par le cache d'instruction L2 pour le préchargement si la ligne de cache associée n'était pas dans le cache d’instruction.
Les techniques modernes n'utilisent plus de seconde unité de prédiction de branchement, mais conservent un ''lookahead program counter''. Par contre, le BTB dispose de plusieurs ports : un pour la prédiction de branchement normale, l'autre pour le préchargement. L'unité de préchargement et l'unité de chargement accèdent toutes deux au BTB quand elles ont besoin de faire leurs prédictions, en parallèle. Typiquement, le BTB est accédé à chaque cycle pour la prédiction de branchement, à un rythme plus faible pour le préchargement.
====Le ''Fetch Directed Instruction Prefetching''====
Les processeurs modernes semblent utiliser un algorithme connu sous le nom de '''''Fetch Directed Instruction Prefetching'''''. Il utilise les adresses contenues dans la FTQ pour précharger les instructions adéquates du cache L2 vers le cache L1 d'instruction (L1i). L'unité de préchargement est placée en aval de la FTQ, elle lit son contenu, détecte quelles adresses correspondent à des lignes de cache à précharger, et envoie celles-ci au cache L2. Le préchargement du L2 vers le L1i a lieu quand le cache L2 est inutilisé, ou du moins quand il peut accepter une nouvelle lecture (dans le cas d'un cache multiport et/ou pipeliné).
[[File:Fetch directed instruction prefetching.png|centre|vignette|upright=2.5|Fetch directed instruction prefetching]]
On peut améliorer légèrement le design précédent sur plusieurs points. Pour éviter de polluer le cache L1 avec des lignes de caches préchargées à tort, il est possible d'ajouter un équivalent des ''stream buffer'' vus dans le chapitre sur le préchargement. Il s'agit d'une autre mémoire FIFO qui mémorise les lignes de cache préchargées. Les lignes de cache préchargées ne sont pas placées dans le cache L1i, mais dans cette file d'attente. Lors d'un accès au L1i, la file d'attente est consultée en parallèle. Si l'instruction voulue est dans la file d'attente, elle est lue depuis la file, et la ligne de cache associée est copiée dans le cache L1i. Mais c'est là une possibilité facultative.
Un autre point est que l'unité de préchargement doit attendre que le cache L2 puisse accepter une nouvelle lecture pour lancer le préchargement d'une autre ligne de cache. Pour corriger cela, on ajoute une file d'attente entre le cache L2 et l'unité de préchargement, qui est évidemment une mémoire FIFO. Son utilité dépend des temps de lectures du cache L2, ainsi que de la taille de la FTQ. Elle n'est pas toujours nécessaire, certains processeurs ont un cache L2 assez lent pour qu'on ne puisse précharger qu'une seule ligne de cache avant que la FTQ soit complétement vide.
Ces deux optimisations sont facultatives, mais elles étaient présentes dans l'article originel qui a proposé la technique.
L'unité de préchargement doit détecter quelles sont les adresses de la FTQ qui ne sont pas déjà chargées dans le L1i. En effet, il est inutile de précharger une ligne de cache si celle-ci est déjà dans le cache L1i. L'unité de préchargement doit donc filtrer au mieux les adresses de la FTQ en deux classes : celles qui correspondent à une ligne de cache déjà dans le L1i, celles qui doivent être préchargées.
Pour cela, l'unité de préchargement utilise la technique dit du '''''Cache Probe Filtering'''''. L'idée part du principe que le cache d'instruction L1 est multiport. Les ports du cache d'instruction ne sont pas toujours utilisés en même temps et il arrive qu'il y ait un port de lecture de libre. Le CPF utilise alors ce port inutilisé pour vérifier si la prochaine ligne de cache à précharger est dans le cache ou non. Si c'est le cas, on aura un succès de cache : la ligne de cache est oubliée, elle ne sera pas préchargée. Si ce n'est pas le cas on aura un défaut de cache : la ligne sera préchargée.
Notez que l'on a pas besoin de lire la ligne en question, juste de vérifier les tags du cache. Dans ce cas, on peut ajouter des signaux de commande spécifiques pour le CPF, qui font une demi-lecture, qui ne vérifie que les tags, mais ne lit pas la donnée. On peut par exemple ajouter un port spécifique pour le CPF, purement en lecture et qui ne permet que de vérifier les tags. Ce port en plus a un cout en circuits plus faible qu'un port de lecture normal, mais ce n'est pas gratuit du tout.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=La prédiction de branchement
| prevText=La prédiction de branchement
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
{{AutoCat}}
pa26y1hewfslhudlv8j8awpixdlvinc
744081
744057
2025-06-03T19:37:19Z
Mewtow
31375
/* La macro-fusion */
744081
wikitext
text/x-wiki
Les processeurs modernes disposent de plusieurs unités de calcul, de bancs de registres larges et de tout un tas d'optimisations permettent d’exécuter un grand nombre d'instructions par secondes. Les opérations de calcul, les accès mémoire : tout cela est très rapide. Mais rien de cela ne fonctionnerait si l'unité de chargement ne suivait pas le rythme. En soi, l'unité de chargement est simple : le ''program counter'', les circuits pour l'incrémenter et gérer les branchements, l'unité de prédiction de branchement, et de quoi communiquer avec le cache. On doit aussi ajouter le registre d'instruction. Difficile de trouver de quoi l'optimiser, à part rendre l'unité de prédiction plus efficace.
Pourtant, les processeurs incorporent diverses optimisations qui rendent le tout beaucoup plus rapide. La plupart de ces optimisations consistent à ajouter des files d'attente ou des mémoires caches dans le ''front-end'', que ce soit après l'étape de chargement ou de décodage. Les caches en question sont situés en aval du cache d'instruction, ce qui en fait des sortes de cache de niveau 0. Les optimisations incluent le préchargement d'instruction, l'usage de files d'attente pour découpler divers circuits et quelques autres. Voyons lesquelles dans ce chapitre.
==La file d'instruction et le cache de macro-opération==
L'unité de chargement contient de nombreux circuits fortement liés entre eux, et on peut découper le tout en plusieurs circuits. L'unité de calcul d'adresse émet les adresses des instructions à charger, qui sont consommées par le cache d'instruction, qui lui-même envoie les instructions lues dans le registre d'instruction ou la file d'instructions. L'unité de calcul d'adresse regroupe : l'unité de prédiction de branchement, le ''program counter'', le circuit pour incrémenter le ''program counter'', les MUX associés pour gérer les branchements.
Le couplage de ces structures fait qu'au moindre défaut de cache d'instruction, l'ensemble stoppe. Par exemple, l'unité de chargement stoppe en cas de défaut de cache. Même chose si jamais une instruction multicycle s’exécute dans le pipeline et bloque toutes les étapes précédentes. Pourtant, il est en théorie possible, et même utile, que certaines structures prennent de l'avance même si d'autres sont bloquées. Par exemple, si le pipeline est bloqué en aval de l'unité de chargement, l'unité de chargement peut en théorie précharger à l'avance des instructions. Ou encore, en cas de défaut de cache d'instruction, l'unité de calcul d'adresse peut précalculer les adresses destinées au cache et les mettre en attente. Pour cela, l'unité de chargement incorpore un paquet de mémoires FIFOs, que nous voir en détail dans ce qui suit.
===Les files d'instruction===
Les processeurs modernes intègrent une '''file d'instruction''', 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 précharger des instructions dans la file d’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 éxecute 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 macro-fusion===
La présence d'une file d'instruction permet d'ajouter une optimisation très importante au processeur, qui ne seraient pas possibles sans elle. L'une d'entre elle est la '''macro-fusion''', une technique qui permet de fusionner une suite d'instructions consécutives en une seule micro-opération. Par exemple, il est possible de fusionner une multiplication suivie d'une addition en une seule instruction MAD (''multiply and add''), si les conditions adéquates sont réunies pour les opérandes. Comme autre exemple, il est possible de fusionner un calcul d'adresse suivi d'une lecture à l'adresse calculée en une seule micro-opération d'accès mémoire. Et enfin, il est possible de fusionner une instruction de test et une instruction de branchement en une seule micro-opération de comparaison-branchement. C'est surtout cette dernière qui est utilisée sur les processeurs Intel modernes.
La macro-fusion est effectuée pendant le décodage, en décodant des instructions dans le tampon d'instruction. Le décodeur reconnait que plusieurs instructions dans le tampon d'instruction peuvent être fusionnées et fournit en sortie la micro-opération équivalent. L'avantage de cette technique est que le chemin de données est utilisé plus efficacement. Notons que la macro-fusion diminue le nombre d'instructions à stocker dans le ROB et dans les différentes mémoires intégrées aux processeur, qui stocke les micro-opérations.
La technique est parfois couplée à un circuit de prédiction, qui détermine si une série d'instruction à fusionner va apparaitre sous peu. L'idée est que dans certains cas, le tampon d'instruction contient le début d'une suite d'instruction combinables. Suivant ce qui sera chargé après, la macro-fusion pourra se faire, ou non. Mais le processeur ne sait pas exactement quelles seront les instructions chargées juste après et il ne sait pas si la macro-fusion aura lieu. Dans ce cas, il peut utiliser un circuit de prédiction de macro-fusion, qui essaye de prédire si les instructions chargées sous peu autoriseront une macro-fusion ou non. Si c'est le cas, les instructions potentiellement fusionnables, le début de la série macro-fusionnable, est mise en attente dans le tampon d'instruction, en attendant les futures instructions.
===Le cache de macro-opérations===
Le cache de macro-opérations est un cache présent en aval de l'unité de chargement, à côté de la file d’instruction. Il mémorise les dernières instructions envoyées à l'unité de décodage, à savoir non pas les instructions préchargées, mais celles qui sont en cours de décodage ou d’exécution, celles qui ont quitté la file d'instruction. Il sert dans le cas où ces instructions sont ré-éxecutées, ce qui est souvent le cas avec des boucles de petite taille.
A chaque cycle d'horloge, ce cache est consulté, de manière à vérifier si l'instruction voulue est dans ce cache ou non. Cela évite un accès au cache d'instruction. Son implémentation est simple : il s'agit d'un petit cache adressé par le ''program counter''. Si l'instruction a été chargée il y a peu, l'instruction machine est mémorisée dans une ligne de cache, le tag de cette ligne n'est autre que son adresse, le ''program counter'' associé. L'accès au cache de macro-opérations est de un seul cycle, pas plus.
[[File:Cache de macro-ops.png|centre|vignette|upright=2|Cache de macro-ops]]
L'intérêt n'est pas évident, mais disons que l'accès à ce cache gaspille moins d'énergie qu’accéder au cache d'instruction. C'est là l'intérêt principal, même s'il se peut qu'on puisse avoir un gain en performance. Le gain en question vient du fait que l'accès est plus rapide dans ce cache, ce qui n'est le cas que dans des conditions précise : si le cache d'instruction est pipeliné et a un temps d'accès de plusieurs cycles.
==La file de micro-opérations et le cache de micro-opérations==
[[File:File d'instruction.png|vignette|upright=1|File d'instruction]]
Sur les processeurs modernes, la sortie du décodeur est reliée à une mémoire FIFO semblable à la file d'instruction, mais placée juste après le décodeur. 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''', par simplicité. 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 continuer à charger et décoder des instructions, et accumuler des instructions décodées dans la file de micro-opérations. En clair, la file de micro-opérations met en attente les instructions 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. Des instructions ne peuvent plus être chargée et 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 peut en profiter pour émettre les instructions en attente dans la file de micro-opérations. 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.
La file de micro-opération est souvent complétée par plusieurs circuits, dont un circuit de micro-fusion, un cache de micro-opérations et le ''loop stream detector''. Voyons ces circuits dans ce qui suit.
[[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 micro-fusion===
La présence d'une file de micro-opération permet d'effectuer une optimisation appelée la '''micro-fusion'''. Elle remplace deux micro-opérations simples consécutives en une seule micro-opération complexe équivalente. Par exemple, sur certains processeurs, le chemin de données est capable d'effectuer une lecture/écriture en adressage base+index en une seule micro-opération. Mais le jeu d'instruction est une architecture Load-store où le calcul d'adresse et l'accès mémoire se font en deux instructions séparées. Dans ce cas, la micro-fusion peut faire son travail et fusionner le calcul d'adresse avec l'accès mémoire.
C'est une optimisation compatible avec la macro-fusion, les deux se ressemblant beaucoup. On peut se demander pourquoi les deux existent ensemble. La raison est historique, et tient au design des processeurs x86. Les premiers processeurs x86 avaient un chemin de données simple, avec les décodeurs qui allaient avec. Sur de tels processeurs, le calcul d'adresse et l'accès mémoire étaient séparés en deux instructions. Puis, avec l'évolution de la technologie, le chemin de données est devenu capable de faire les deux en une seule micro-opération. Pour compenser cela, Intel et AMD auraient pu changer leurs décodeurs en profondeur. A la place, ils ont préféré utiliser la technique de la micro-fusion, par simplicité.
===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 passons). 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 conserve les N dernières instructions décodées dans la file d'instruction, qui se comporte alors comme une sorte de pseudo-cache FIFO. Un circuit annexe, appelé le ''Loop Stream Detector'' (LSD), détecte lesboucles dans la file de micro-opérations et optimise leur exécution. Avec un LSD, la file d'instruction 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. 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, à 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.
L'optimisation 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]]
Les CPU Intel modernes disposent d'un ''loop stream detector'', les CPU AMD en avaient sur les microarchitectures Zen 4 mais il a disparu sur la microarchitecture Zen 5. Quelques CPU ARM avaient aussi un ''loop stream detector'', notamment le Cortex A15. É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. 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. Pour donner quelques chiffres, les processeurs ARM Cortex A15 géraient des boucles de maximum 32 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.
Il faut noter que le ''loop stream detector'' a été désactivé par des mises à jour de microcode sur quelques architectures, comme sur la microarchitecture Zen 4 d'AMD ou les CPU de microarchitecture Skylake et Kaby Lake d'Intel. Pour la microarchitecture 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 décode les instructions en micro-opérations, qui sont accumulées dans le cache de micro-opérations. Les itérations suivantes de la boucle vont chercher les micro-opérations adéquates dans le cache de micro-opération : on n'a pas à décoder l'instruction une nouvelle fois.
Intuitivement, vous vous dites que son implémentation la plus simple mémorise les N dernières micro-opérations exécutées par le processeur, ce qui en fait un cache FIFO. Mais la réalité est que c'est déjà ce qui est fait par le couple LSD + file de micro-opération. 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. De plus, le cache de micro-opération est séparé de la file de micro-opération. Et il est alimenté non pas par l'unité de décodage, mais par la file de micro-opérations. Ce sont les micro-opérations qui quittent la file de micro-opérations qui sont insérées dans le cache, pas celles qui quittent directement le décodeur.
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.
La différence avec le cache de micro-opération est que la 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, avec un cache de macro/micro-opération, on pourra lire les instructions précédant le IF...ELSE dedans. Le cache de micro-opération est donc plus efficace que le ''Loop Stream Detector'', mais pour un cout en transistor plus élevé.
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.png|centre|vignette|upright=2|File de micro-opérations et cache de micro-ops]]
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 microarchitecture Skylake, chaque ligne de cache était associée à une instruction machine et pouvait contenir de 1 à 6 micro-opérations. La suite de micro-opérations correspondant à 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.
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. Le cache de micro-opération est une voie de chargement parallèle au ''front-end'' proprement dit. 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.
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 l'é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 émission d'instruction, pour limiter la consommation d'énergie an détriment des performances. Ces limitations ne sont pas présentes sur les architectures récentes.
Aussi bien le cache de macro-opérations que le cache de micro-opérations optimisent l'exécution des boucles, mais ils ne sont pas au même endroit dans le pipeline : avant et après l'unité de décodage. Et le premier mémorise des instructions machines, l'autre des micro-opérations décodées. Les avantages et inconvénients sont totalement différents. Niveau capacité des deux caches, l'encodage des instructions machines est plus compact que la ou les micro-instructions équivalente, ce qui est un avantage pour le cache de macro-opérations à capacité équivalente. Par contre, le cache de micro-opérations permet de désactiver les décodeurs en cas de succès de cache, vu que les instructions ne doivent plus être décodées et renommées. Le gain est d'autant plus important si les instructions ont un encodage complexe, ou si les instructions sont à longueur variable, ce qui rend leur décodage complexe et donc lent. Globalement, plus le décodage est complexe et/ou long, plus le cache de micro-opérations fait des merveilles.
==Le préchargement d'instructions et la ''Fetch Target Queue''==
Les processeurs modernes incorporent une optimisation assez intéressante : ils découplent l'unité de prédiction de branchement et le ''program counter'' de l'accès au cache d'instruction. Pour cela, ils incorporent une mémoire FIFO entre l'unité de prédiction de branchement et le cache d'instruction. 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/écrire 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 !
Un avantage de la FTQ tient dans le fait que les caches d'instructions sont pipelinés, sur le même modèle que les processeurs. On peut leur envoyer une demande de lecture/écriture par cycle, alors que chaque lecture/écriture prendra plusieurs cycles à s'effectuer. L'accès au cache d'instruction a donc une certaine latence, qui est partiellement masquée par la FTQ au point où elle ne s'exprime qu'en cas de défaut de cache assez important. Par exemple, si l'accès au cache d'instruction prend 4 cycles, une FTQ qui met en attente 4 adresses camouflera le temps d'accès au cache, tant qu'il n'y a pas de mauvaise prédiction de branchement. La FTQ est aussi très utile avec les unités de branchement modernes, qui peuvent mettre plusieurs cycles pour fournir une prédiction. Prendre de l'avance avec une FTQ amorti partiellement le temps de calcul des prédictions.
: Si le cache d'instruction est multiport et accepte plusieurs accès simultanés, il peut consommer plusieurs entrées dans la FTQ à la fois.
Mais l'avantage principal de la FTQ est qu'elle permet l'implémentation d'une optimisation très importante. Il y a quelques chapitres, nous avions parlé des techniques de '''préchargement d'instruction''', qui permettent de charger à l'avance des instructions dans le cache d'instruction. Nous avions volontairement laissé de côté le préchargement des instructions, pour tout un tas de raisons. Et la raison est justement que la prédiction de branchement et le préchargement des instructions sont fortement liés sur les processeurs modernes. Il est maintenant possible d'aborder le préchargement pour les instructions, d’où cette section.
Notons que par préchargement des instructions, on peut parler de deux formes de préchargement, fortement différentes. La première correspond au préchargement normal, à savoir le préchargement des instructions dans le cache d'instruction L1, à partir du cache L2. Il s'agit donc d'un préchargement dans le cache d'instruction. Mais il existe aussi une autre forme de préchargement, qui consiste à précharger à l'avance des instructions dans la file d'instruction et qui a été abordée dans la section sur la ''prefetch input queue''. Les deux formes de préchargement n'ont pas lieu au même endroit dans la hiérarchie mémoire : l'une précharge du cache L2 vers le L1i, l'autre du cache L1i vers la file d'instruction (ou dans le cache de macro-opération). Mais les algorithmes utilisés pour sont sensiblement les mêmes. Aussi, nous allons les voir en même temps. Pour faire la distinction, nous parlerons de préchargement L2-L1i pour la première, de préchargement interne pour l'autre.
===Les algorithmes de préchargement d'instructions===
Les techniques basiques de préchargement consistent à charger des instructions qui suivent la dernière ligne de cache accédée. Quand on charge des instructions dans le cache d’instruction, les instructions qui suivent sont chargées automatiquement, ligne de cache par ligne de cache. il s'agit due préchargement séquentiel, la technique la plus simple de préchargement, qui profite de la localité spatiale. Elle est utilisée pour précharger des instructions du cache L2 vers le cache L1i, mais aussi pour le préchargement interne dans la file d'instructions.
[[File:Branchements et préchargement séquentiel.png|centre|vignette|upright=2|Branchements et préchargement séquentiel.]]
Mais un ''prefetcher'' purement séquentiel gère mal les branchements. Si un branchement est pris, les instructions de destination ne sont pas chargées, si elles ne sont pas dans la ligne de cache suivante. Pour le préchargement L2-L1i, cela ne pose pas de problèmes majeurs, au-delà de la pollution du cache L1i par des instructions inutiles. Mais pour le préchargement interne, c'est autre chose. 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.
Il existe des techniques de préchargement plus élaborées qui marchent mieux en présence de branchements. Elles utilisent toutes une collaboration de l'unité de prédiction de branchement. Elles accèdent au ''Branch Target Buffer'', pour détecter les branchements, leur destination, etc. Le tout peut se coupler à la technique du prédécodage. Avec cette dernière, le prédécodage décode en partie les instructions lors de leur chargement dans le cache, et détecte les branchements et leur adresse de destination à ce moment-là. Ces informations sont alors mémorisées dans une table à part, ou dans le BTB. Mais la plupart des designs utilisent le BTB, par souci de simplicité. Il existe globalement deux à trois techniques principales, que nous allons voir dans ce qui suit.
La première technique prédit si le branchement est pris ou non, et agit différemment si le branchement est pris ou non. Si le branchement est pris, elle précharge les instructions à partir de l'adresse de destination des branchements pris. Sinon, elle précharge les instructions suivantes avec préchargement séquentiel. Il s'agit du '''''target line prefetching'''''
[[File:Target line prefetching.png|centre|vignette|upright=2|Target line prefetching.]]
Une autre technique ne prédit pas les branchements et précharge à la fois les instructions suivantes avec le ''next-line prefetching'', et la ligne de cache de destination du branchement avec le ''target line prefetching''. Comme ça, peu importe que le branchement soit pris ou non, les instructions adéquates seront préchargées quand même. On appelle cette technique le '''préchargement du mauvais chemin''' (''wrong path prefetching'').
[[File:Préchargement du mauvais chemin.png|centre|vignette|upright=2|Préchargement du mauvais chemin.]]
Le ''target line prefetching'' est plus complexe à implémenter, car il demande de prédire les branchements. Mais elle a l'avantage de ne pas précharger inutilement deux lignes de cache par branchement, seulement une seule. Par contre, le préchargement est inutile en cas de mauvaise prédiction de branchement : non seulement on a préchargé une ligne de cache inutilement, mais en plus, la ligne de cache adéquate n'a pas été chargée. On n'a pas ce problème avec le préchargement du mauvais chemin, qui garantit que la ligne de cache adéquate est toujours préchargée.
===L'implémentation du préchargement interne, dans la file d'instruction===
Le préchargement dans la file d'instruction est généralement de type séquentiel, mais certains processeurs font autrement. Déjà, il faut remarquer que le ''target line prefetching'' correspond en réalité à la prédiction de branchement classique. L'adresse de destination est prédite, et on charge les instructions adéquates dans la file d'instruction. La prédiction de branchement, associée à une file d'instruction, est donc une forme de préchargement. Il fallait y penser. Enfin, des processeurs assez rares utilisaient le 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 dans laquelle on utilise la prédiction de branchement pour faire du ''target line prefetching''. Une fois que l'on sait si la prédiction de branchement était correcte, on est certain qu'une des deux files contiendra les instructions valides. Le contenu de la file adéquate est conservé, alors que l'autre est intégralement invalidée. Le choix de la bonne file se fait avec un multiplexeur. C'est approximativement la technique qui était implémentée sur le processeur de mainframe IBM 370/165, par exemple, et sur quelques modèles IBM similaires.
Le problème est que cette méthode demande de charger deux instructions à chaque cycle. Cela demande donc d'utiliser un cache d'instruction multiport, avec un port par file d'instruction. Le cout en circuit d'un cache double port n'est pas négligeable. Et le gain en performance est assez faible. Le préchargement dans la file d’instruction permet d'économiser quelques cycles lors de l'accès au cache d'instruction, guère plus. Le gain est maximal lorsque les instructions préchargées ont généré un défaut de cache, qui a rapatrié les instructions adéquates pendant que le processeur exécutait les mauvaises instructions, avant que la mauvaise prédiction de branchement soit détectée. Dans ce cas, le défaut de cache a eu lieu pendant la mauvaise prédiction et sa réparation, et non après.
====La gestion des branchements successifs====
Un autre défaut de cette méthode est la présence de branchements successifs. Par exemple, si jamais on rencontre un branchement, le flux d'instructions se scinde en deux : 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 méthode la plus simple stoppe le chargement du flux en attendant que le premier branchement soit terminé. Cette solution est intuitive, mais est celle où on a les gains en performance les plus faibles. Elle est couramment implémentée d'une manière assez particulière, qui ne correspond pas tout à fait à un stop du chargement, mais qui utilise les lignes de cache. L'unité de préchargement est conçue pour copier des lignes de cache entières dans la file d'instruction. Le processeur (pré-)charge deux lignes de cache : celle du bon chemin, celle du mauvais chemin. Il les précharge dans deux files d'instructions, qui contiennent généralement une ligne de cache grand maximum. Le temps que l'on ait chargé les deux files d'instruction, le résultat du branchement est connu et on sait laquelle est la bonne.
L'autre possibilité est d'utiliser la prédiction de branchement pour ce flux, afin de poursuivre le chargement de manière spéculative. Elle donne de bonnes performances, mais demande des unités de prédiction de branchement spéciales, dans le cas où les deux flux tombent sur un branchement en même temps. Cette technique est indirectement liée au cache de traces que nous verrons dans le chapitre sur les processeurs superscalaires. Nous n'en parlons pas ici, car ce genre de techniques est plus liée aux processeurs superscalaires qu'un processeur avec un pipeline normal.
Une autre possibilité consiste à scinder ce flux en deux et charger les deux sous-flux. 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.
[[File:Exécution stricte 01.png|centre|vignette|upright=2|Exécution stricte, seconde.]]
====Les processeurs à exécution de chemins multiples====
L'idée précédente peut en théorie être améliorée, afin de non seulement charger les instructions en provenance des deux chemins (celui du branchement pris, et celui du branchement non pris), mais aussi de les exécuter : c'est ce qu'on appelle l''''exécution stricte''' (''eager execution''). Bien sûr, on n’est pas limité à un seul branchement, mais on peut poursuivre un peu plus loin.
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 destinés à la 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. Il y a le même problème avec le préchargement interne simple, quand on utilise le préchargement du mauvais chemin, comme vu juste au-dessus.
===L'implémentation matérielle du préchargement de cache L2-L1i===
Pour comprendre comment s'effectue le préchargement L2-L1i, il faut regarder comment l'unité de chargement communique avec les caches. L'unité de prédiction de branchement est généralement regroupée avec le ''program counter'' et les circuits associés (les incrémenteurs/MUX associés), pour former l'unité de chargement proprement dite. L'unité de chargement émet des adresses consommées par le cache d'instruction, qui lui-même envoie les instructions lues dans le registre d'instruction ou la file d'instructions.
Le couplage de ces structures fait qu'au moindre défaut de cache d'instruction, l'ensemble stoppe. Et notamment, l'unité de prédiction de branchement stoppe en cas de défaut de cache. Même chose si jamais une instruction multicycle s’exécute dans le pipeline et bloque toutes les étapes précédentes. Les pertes de performance ne sont pas très importantes, mais elles existent. Et le préchargement se manifeste dans ces situations.
Le préchargement d'instructions consiste à découpler ces structures de manière à ce qu'elles fonctionnent plus ou moins indépendamment. Le but est qu'en plus des accès normaux au cache d'instruction, l'unité de chargement envoie des informations au cache L2 ou L1i en avance, pour effectuer le préchargement. L'unité de chargement doit alors prendre de l'avance sur le cache, pour effectuer les accès au cache L2 en avance, tout en maintenant l'état normal pour effectuer les accès normaux. C'est donc plus ou moins l'unité de chargement qui s'occupe du préchargement, ou du moins les deux sont très liées.
====L'anticipation du ''program counter''====
Avec la solution la plus simple, on a une unité de chargement qui s'occupe des accès au cache d'instruction, et une unité de préchargement qui prend de l'avance sur l'unité de chargement, et communique avec le cache L2. La technique la plus basique se base sur un ''Lookahead program counter'', un second ''program counter'' qui ne fonctionne que lors d'un défaut de cache d'instruction. Il est initialisé avec le ''program counter'' lors d'un défaut de cache, puis il est incrémenté à chaque cycle et les branchements sont prédits, ce qui fait qu'il est mis à jour comme si l’exécution du programme se poursuivait, alors que le reste du processeur est mis en attente.
La technique initiale utilisait ce second ''program counter'' pour accéder à une table de prédiction, qui associe à chaque valeur du ''program counter'', l'adresse des données chargées par l'instruction associée. Les adresses fournies à chaque cycle par cette table sont alors envoyées aux unités de préchargement pour qu'elles fassent leur travail. La technique permettait donc de précharger des données en cas de défaut de cache, mais pas d'instructions. Il ne s'agissait pas d'une technique de préchargement des instructions, mais de préchargement de données.
La technique a ensuite été adaptée pour le chargement des instructions par Chen, Lee et Mudge. Leur idée utilisait deux unités de prédiction de branchements : une couplée à l'unité de chargement, l'autre pour le préchargement. La première utilisait le ''program counter'' normal, l'autre se déclenchait en cas de défaut de cache et utilisait un ''lookahead program counter''. Les adresses générées par le ''lookahead program counter'' étaient envoyée au cache d'instruction, sur un port de lecture séparé. La ligne de cache lue était alors prédécodée pour détecter les branchements, qui étaient prédits, et rebelote. Il est possible d'adapter la méthode pour que les adresses soient accumulées dans une mémoire FIFO, et étaient consommée par le cache d'instruction L2 pour le préchargement si la ligne de cache associée n'était pas dans le cache d’instruction.
Les techniques modernes n'utilisent plus de seconde unité de prédiction de branchement, mais conservent un ''lookahead program counter''. Par contre, le BTB dispose de plusieurs ports : un pour la prédiction de branchement normale, l'autre pour le préchargement. L'unité de préchargement et l'unité de chargement accèdent toutes deux au BTB quand elles ont besoin de faire leurs prédictions, en parallèle. Typiquement, le BTB est accédé à chaque cycle pour la prédiction de branchement, à un rythme plus faible pour le préchargement.
====Le ''Fetch Directed Instruction Prefetching''====
Les processeurs modernes semblent utiliser un algorithme connu sous le nom de '''''Fetch Directed Instruction Prefetching'''''. Il utilise les adresses contenues dans la FTQ pour précharger les instructions adéquates du cache L2 vers le cache L1 d'instruction (L1i). L'unité de préchargement est placée en aval de la FTQ, elle lit son contenu, détecte quelles adresses correspondent à des lignes de cache à précharger, et envoie celles-ci au cache L2. Le préchargement du L2 vers le L1i a lieu quand le cache L2 est inutilisé, ou du moins quand il peut accepter une nouvelle lecture (dans le cas d'un cache multiport et/ou pipeliné).
[[File:Fetch directed instruction prefetching.png|centre|vignette|upright=2.5|Fetch directed instruction prefetching]]
On peut améliorer légèrement le design précédent sur plusieurs points. Pour éviter de polluer le cache L1 avec des lignes de caches préchargées à tort, il est possible d'ajouter un équivalent des ''stream buffer'' vus dans le chapitre sur le préchargement. Il s'agit d'une autre mémoire FIFO qui mémorise les lignes de cache préchargées. Les lignes de cache préchargées ne sont pas placées dans le cache L1i, mais dans cette file d'attente. Lors d'un accès au L1i, la file d'attente est consultée en parallèle. Si l'instruction voulue est dans la file d'attente, elle est lue depuis la file, et la ligne de cache associée est copiée dans le cache L1i. Mais c'est là une possibilité facultative.
Un autre point est que l'unité de préchargement doit attendre que le cache L2 puisse accepter une nouvelle lecture pour lancer le préchargement d'une autre ligne de cache. Pour corriger cela, on ajoute une file d'attente entre le cache L2 et l'unité de préchargement, qui est évidemment une mémoire FIFO. Son utilité dépend des temps de lectures du cache L2, ainsi que de la taille de la FTQ. Elle n'est pas toujours nécessaire, certains processeurs ont un cache L2 assez lent pour qu'on ne puisse précharger qu'une seule ligne de cache avant que la FTQ soit complétement vide.
Ces deux optimisations sont facultatives, mais elles étaient présentes dans l'article originel qui a proposé la technique.
L'unité de préchargement doit détecter quelles sont les adresses de la FTQ qui ne sont pas déjà chargées dans le L1i. En effet, il est inutile de précharger une ligne de cache si celle-ci est déjà dans le cache L1i. L'unité de préchargement doit donc filtrer au mieux les adresses de la FTQ en deux classes : celles qui correspondent à une ligne de cache déjà dans le L1i, celles qui doivent être préchargées.
Pour cela, l'unité de préchargement utilise la technique dit du '''''Cache Probe Filtering'''''. L'idée part du principe que le cache d'instruction L1 est multiport. Les ports du cache d'instruction ne sont pas toujours utilisés en même temps et il arrive qu'il y ait un port de lecture de libre. Le CPF utilise alors ce port inutilisé pour vérifier si la prochaine ligne de cache à précharger est dans le cache ou non. Si c'est le cas, on aura un succès de cache : la ligne de cache est oubliée, elle ne sera pas préchargée. Si ce n'est pas le cas on aura un défaut de cache : la ligne sera préchargée.
Notez que l'on a pas besoin de lire la ligne en question, juste de vérifier les tags du cache. Dans ce cas, on peut ajouter des signaux de commande spécifiques pour le CPF, qui font une demi-lecture, qui ne vérifie que les tags, mais ne lit pas la donnée. On peut par exemple ajouter un port spécifique pour le CPF, purement en lecture et qui ne permet que de vérifier les tags. Ce port en plus a un cout en circuits plus faible qu'un port de lecture normal, mais ce n'est pas gratuit du tout.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=La prédiction de branchement
| prevText=La prédiction de branchement
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
{{AutoCat}}
m320e057kfkff4frmhhdt0zxpsj9052
Les cartes graphiques/La mémoire unifiée et la mémoire vidéo dédiée
0
80571
744045
744007
2025-06-03T14:29:34Z
Mewtow
31375
/* Historique de la mémoire virtuelle sur les GPU */
744045
wikitext
text/x-wiki
Pour rappel, il existe deux types de cartes graphiques : les cartes dédiées et les cartes intégrées. Les '''cartes graphiques dédiées''' sont des cartes graphiques branchées sur des connecteurs/ports de la carte mère. A l'opposé, tous les processeurs modernes intègrent une carte graphique, appelée '''carte graphique intégrée''', ou encore '''IGP''' (''Integrated Graphic Processor''). En somme, les cartes dédiées sont opposées à celles intégrées dans les processeurs modernes.
La différence a un impact sur la mémoire vidéo. Les cartes graphiques dédiées ont souvent de la mémoire vidéo intégrée à la carte graphique. Il y a des exceptions, mais on en parlera plus tard. Les cartes graphiques intégrées au processeur n'ont pas de mémoire vidéo dédiée, vu qu'on ne peut pas intégrer beaucoup de mémoire vidéo dans un processeur. La conséquence est qu'il existe deux grandes manières d'organiser la mémoire à laquelle la carte graphique a accès.
* La première est celle de la '''mémoire vidéo dédiée''', à savoir que la carte graphique dispose de sa propre mémoire rien qu'à elle, séparée de la mémoire RAM de l'ordinateur. On fait alors la distinction entre ''RAM système'' et ''RAM vidéo''. Si les premières cartes graphiques n'avaient que quelques mégaoctets de RAM dédiée, elles disposent actuellement de plusieurs gigas-octets de RAM.
* A l'opposé, on trouve la '''mémoire unifiée''', avec une seule mémoire RAM est partagée entre le processeur et la carte graphique. Le terme "unifiée" sous-entend que l'on a unifié la mémoire vidéo et la mémoire système (la RAM).
[[File:Répartition de la mémoire entre RAM système et carte graphique.png|centre|vignette|upright=2.5|Répartition de la mémoire entre RAM système et carte graphique]]
Dans la grosse majorité des cas, les cartes vidéos dédiées ont une mémoire dédiée, alors que les cartes graphiques intégrées doivent utiliser la mémoire unifiée. Mais outre les cartes dédiées et intégrées, il faut aussi citer les cartes graphiques soudées sur la carte mère. Elles étaient utilisées sur les consoles de jeu vidéos assez anciennes, elles sont encore utilisées sur certains PC portables puissants, destinés aux ''gamers''. Pour ces dernières, il est possible d'utiliser aussi bien de la mémoire dédiée que de la mémoire unifiée. D'anciennes consoles de jeu avaient une carte graphique soudée sur la carte mère, qu'on peut facilement repérer à l’œil nu, avec une mémoire unifiée. C'est notamment le cas sur la Nintendo 64, pour ne citer qu'elle. D'autres avaient leur propre mémoire vidéo dédiée.
L'usage d'une carte vidéo dédiée se marie très bien avec une mémoire vidéo dédiée, mais il existe de nombreux cas où une carte vidéo dédiée est associée à de la mémoire unifiée. Comme exemple, la toute première carte graphique AGP, l'Intel 740, ne possédait pas de mémoire vidéo proprement dite, juste un simple ''framebuffer''. Tout le reste, texture comme géométrie, était placé en mémoire système et la carte graphique allait lire/écrire les données directement en mémoire RAM système ! Les performances sont généralement ridicules, pour des raisons très diverses, mais les cartes de ce type sont peu chères. Outre l'économie liée à l'absence de mémoire vidéo, les cartes graphiques de ce type sont peu puissantes, l'usage de la mémoire unifiée simplifie leur conception, etc. Par exemple, l'Intel 740 a eu un petit succès sur les ordinateurs d'entrée de gamme.
==Le partage de la mémoire unifiée==
Avec la mémoire unifiée, la quantité de mémoire système disponible pour la carte graphique est généralement réglable avec un réglage dans le BIOS. On peut ainsi choisir d'allouer 64, 128 ou 256 mégaoctets de mémoire système pour la carte vidéo, sur un ordinateur avec 4 gigaoctets de RAM. L'interprétation de ce réglage varie grandement selon les cartes mères ou l'IGP.
Pour les GPU les plus anciens, ce réglage implique que la RAM sélectionnée est réservée uniquement à la carte graphique, même si elle n'en utilise qu'une partie. La répartition entre mémoire vidéo et système est alors statique, fixée une fois pour toutes. Dans ce cas, la RAM allouée à la carte graphique est généralement petite par défaut. Les concepteurs de carte mère ne veulent pas qu'une trop quantité de RAM soit perdu et inutilisable pour les applications. Ils brident donc la carte vidéo et ne lui allouent que peu de RAM.
Heureusement, les GPU modernes sont plus souples. Ils fournissent deux réglages : une quantité de RAM minimale, totalement dédiée au GPU, et une quantité de RAM maximale que le GPU ne peut pas dépasser. Par exemple, il est possible de régler le GPU de manière à ce qu'il ait 64 mégaoctets rien que pour lui, mais qu'il puisse avoir accès à maximum 1 gigaoctet s'il en a besoin. Cela fait au total 960 mégaoctets (1024-64) qui peut être alloués au choix à la carte graphique ou au reste des programmes en cours d’exécution, selon les besoins. Il est possible d'allouer de grandes quantités de RAM au GPU, parfois la totalité de la mémoire système.
[[File:Partage de la mémoire unifiée entre CPU et GPU.png|centre|vignette|upright=2|Répartition de la mémoire entre RAM système et carte graphique]]
==Le partage de la mémoire système : la mémoire virtuelle des GPUs dédiés==
Après avoir vu la mémoire unifiée, voyons maintenant la mémoire dédiée. Intuitivement, on se dit que la carte graphique n'a accès qu'à la mémoire vidéo dédiée et ne peut pas lire de données dans la mémoire système, la RAM de l'ordinateur. Cependant, ce n'est pas le cas. Et ce pour plusieurs raisons. La raison principale est que des données doivent être copiées de la mémoire RAM vers la mémoire vidéo. Les copies en question se font souvent via ''Direct Memory Access'', ce qui fait que les GPU intègrent un contrôleur DMA dédié. Et ce contrôleur DMA lit des données en RAM système pour les copier en RAM vidéo.
La seconde raison est que les cartes graphiques intègrent presque toutes des technologies pour lire directement des données en RAM, sans forcément les copier en mémoire vidéo. Les technologies en question permettent à la carte graphique d'adresser plus de RAM qu'en a la mémoire vidéo. Par exemple, si la carte vidéo a 4 giga-octets de RAM, la carte graphique peut être capable d'en adresser 8 : 4 gigas en RAM vidéo, et 4 autres gigas en RAM système.
Les technologies de ce genre ressemblent beaucoup à la mémoire virtuelle des CPU, avec cependant quelques différences. La mémoire virtuelle permet à un processeur d'utiliser plus de RAM qu'il n'y en a d'installée dans l'ordinateur. Par exemple, elle permet au CPU de gérer 4 gigas de RAM sur un ordinateur qui n'en contient que trois, le gigaoctet de trop étant en réalité simulé par un fichier sur le disque dur. La technique est utilisée par tous les processeurs modernes. La mémoire virtuelle des GPUs dédiés fait la même chose, sauf que le surplus d'adresses n'est pas stockés sur le disque dur dans un fichier pagefile, mais est dans la RAM système. Pour le dire autrement, ces cartes dédiées peuvent utiliser la mémoire système si jamais la mémoire vidéo est pleine.
[[File:Mémoire virtuelle des cartes graphiques dédiées.png|centre|vignette|upright=2|Mémoire virtuelle des cartes graphiques dédiées]]
===La ''IO-Memory Management Unit'' (IOMMU)===
Pour que la carte graphique ait accès à la mémoire système, elle intègre un circuit appelé la '''''Graphics address remapping table''''', abrévié en GART. Cela vaut aussi bien pour les cartes graphiques utilisant le bus AGP que pour celles en PCI-Express. La GART est techniquement une une ''Memory Management Unit'' (MMU), à savoir un circuit spécialisé qui prend en charge la mémoire virtuelle. La dite MMU étant intégrée dans un périphérique d'entrée-sortie (IO), ici la carte graphique, elle est appelée une IOMMU.
L'espace d'adressage est l'ensemble des adresses géré par le processeur ou la carte graphique. En théorie, l'espace d'adressage du processeur et de la carte graphique sont séparés, mais des standards comme l''''''Heterogeneous System Architecture''''' permettent au processeur et à une carte graphique de partager le même espace d'adressage. Une adresse mémoire est alors la même que ce soit pour le processeur ou la carte graphique.
{|class="wikitable"
|-
! !! Mémoire vidéo dédiée !! Mémoire vidéo unifiée
|-
! Sans HSA
| [[File:Desktop computer bus bandwidths.svg|400px|Desktop computer bus bandwidths]]
| [[File:Integrated graphics with distinct memory allocation.svg|400px|Integrated graphics with distinct memory allocation]]
|-
! Avec HSA
| [[File:HSA-enabled virtual memory with distinct graphics card.svg|400px|HSA-enabled virtual memory with distinct graphics card]]
| [[File:HSA-enabled integrated graphics.svg|400px|HSA-enabled integrated graphics]]
|}
===Historique de la mémoire virtuelle sur les GPU===
La technologie existait déjà sur certaines cartes graphiques au format PCI, mais la documentation est assez rare. La carte graphique NV1 de NVIDIA, leur toute première carte graphique, disposait déjà de ce système de mémoire virtuelle. La carte graphique communiquait avec le processeur grâce à la fonctionnalité ''Direct Memory Access'' et intégrait donc un contrôleur DMA. Le ''driver'' de la carte graphique programmait le contrôleur DMA en utilisant les adresses fournies par les applications/logiciels. Et il s'agissait d'adresses virtuelles, non d'adresses physiques en mémoire RAM. Pour résoudre ce problème, le contrôleur DMA intégrait une MMU, une unité de traduction d'adresse, qui traduisait les adresses virtuelles fournies par les applications en adresse physique en mémoire système.
: Le fonctionnement de cette IOMMU est décrite dans le brevet "US5758182A : DMA controller translates virtual I/O device address received directly from application program command to physical i/o device address of I/O device on device bus", des inventeurs David S. H. Rosenthal et Curtis Priem.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
La technologie s'est démocratisée avec le bus AGP, dont la fonctionnalité dite d'''AGP texturing'' permettait de lire ou écrire directement dans la mémoire RAM, sans passer par le processeur. D'ailleurs, la carte graphique Intel i740 n'avait pas de mémoire vidéo et se débrouillait uniquement avec la mémoire système.
L'arrivée du bus PCI-Express ne changea pas la donne, si ce n'est que le bus était plus rapide, ce qui améliorait les performances. Au début, seules les cartes graphiques PCI-Express d'entrée de gamme pouvaient accéder à certaines portions de la mémoire RAM grâce à des technologies adaptées, comme le TurboCache de NVIDIA ou l'HyperMemory d'AMD. Mais la technologie s'est aujourd'hui étendue. De nos jours, toutes les cartes vidéos modernes utilisent la RAM système en plus de la mémoire vidéo, mais seulement en dernier recours, soit quand la mémoire vidéo est quasiment pleine, soit pour faciliter les échanges de données avec le processeur. C'est typiquement le pilote de la carte graphique qui décide ce qui va dans la mémoire vidéo et la mémoire système, et il fait au mieux de manière à avoir les performances optimales.
==Les échanges entre processeur et mémoire vidéo==
Quand on charge un niveau de jeux vidéo, on doit notamment charger la scène, les textures, et d'autres choses dans la mémoire RAM, puis les envoyer à la carte graphique. Ce processus se fait d'une manière fortement différente selon que l'on a une mémoire unifiée ou une mémoire vidéo dédiée.
===Avec la mémoire unifiée===
Avec la mémoire unifié, les échanges de données entre processeur et carte graphique sont fortement simplifiés et aucune copie n'est nécessaire. La carte vidéo peut y accéder directement, en lisant leur position initiale en RAM. Une partie de la RAM est visible seulement pour le CPU, une autre seulement pour le GPU, le reste est partagé. Les échanges de données entre CPU et GPU se font en écrivant/lisant des données dans la RAM partagée entre CPU et GPU. Pas besoin de faire de copie d'une mémoire à une autre : la donnée a juste besoin d'être placée au bon endroit. Le chargement des textures, du tampon de commandes ou d'autres données du genre, est donc très rapide, presque instantané.
Par contre, le débit de la RAM unifiée est partagé entre la carte graphique et le processeur. Alors qu'avec une mémoire dédiée, tout le débit de la mémoire vidéo aurait été dédié au GPU, le CPU ayant quant à lui accès à tout le débit de la RAM système. De plus, le partage du débit n'est pas chose facile. Les deux se marchent sur les pieds. La carte graphique doit parfois attendre que le processeur lui laisse l'accès à la RAM et inversement. Divers circuits d'arbitrage s'occupent de répartir équitablement les accès à RAM entre les deux, mais cela ne permet que d'avoir un compromis imparfait qui peut réduire les performances. Le seul moyen pour réduire la casse est d'ajouter des mémoires caches entre le GPU et la RAM, mais l'efficacité des caches est relativement limitée pour le rendu 3D.
[[File:Echanges de données entre CPU et GPU avec une mémoire unifiée.png|centre|vignette|upright=2|Échanges de données entre CPU et GPU avec une mémoire unifiée]]
Un autre défaut survient sur les cartes dédiées à mémoire unifiée, par exemple l'Intel 740. Pour lire en mémoire RAM, elles doivent passer par l'intermédiaire du bus AGP, PCI ou PCI-Express. Et ce bus est très lent, bien plus que ne le serait une mémoire vidéo normale. Aussi, les performances sont exécrables. J'insiste sur le fait que l'on parle des cartes graphiques dédiées, mais pas des cartes graphiques soudées des consoles de jeu.
D'ailleurs, de telles cartes dédiées incorporent un ''framebuffer'' directement dans la carte graphique. Il n'y a pas le choix, le VDC de la carte graphique doit accéder à une mémoire suffisamment rapide pour alimenter l'écran. Ils ne peuvent pas prendre le risque d'aller lire la RAM, dont le temps de latence est élevé, et qui peut potentiellement être réservée par le processeur pendant l’affichage d'une image à l'écran.
===Avec une mémoire vidéo dédiée===
Avec une mémoire vidéo dédiée, on doit copier les données adéquates dans la mémoire vidéo, ce qui implique des transferts de données passant par le bus PCI-Express. Le processeur voit une partie de la mémoire vidéo, dans laquelle il peut lire ou écrire comme bon lui semble. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Il est possible pour le CPU de copier des données dans la portion invisible de la mémoire vidéo, mais cela se fait de manière indirecte en passant par le GPU d'abord. Il faut typiquement envoyer une commande spéciale au GPU, pour lui dire de charger une texture en mémoire vidéo, par exemple. Le GPU effectue alors une copie de la mémoire système vers la mémoire vidéo, en utilisant un contrôleur DMA intégré au GPU.
[[File:Interaction du GPU avec la mémoire vidéo et la RAM système sur une carte graphique dédiée.png|centre|vignette|upright=2|Échanges de données entre CPU et GPU avec une mémoire vidéo dédiée]]
La gestion de la mémoire vidéo est prise en charge par le pilote de la carte graphique, sur le processeur. Elle a tendance à allouer les textures et d'autres données de grande taille dans la mémoire vidéo invisible, le reste étant placé ailleurs. Les copies DMA vers la mémoire vidéo invisible sont adaptées à des copies de grosses données comme les textures, mais elles marchent mal pour des données assez petites. Or, les jeux vidéos ont tendance à générer à la volée de nombreuses données de petite taille, qu'il faut copier en mémoire vidéo. Et c'est sans compter sur des ressources du pilote de périphériques, qui doivent être copiées en mémoire vidéo, comme le tampon de commande ou d'autres ressources. Et celles-ci ne peuvent pas forcément être copiées dans la mémoire vidéo invisible. Si la mémoire vidéo visible par le CPU est trop petite, les données précédentes sont copiées dans la mémoire visible par le CPU, en mémoire système, mais leur accès par le GPU est alors très lent. Aussi, plus la portion visible de la mémoire vidéo est grande, plus simple est la gestion de la mémoire vidéo par le pilote graphique. Et de ce point de vue, les choses ont évolué récemment.
Pour accéder à un périphérique PCI-Express, il faut configurer des registres spécialisés, appelés les ''Base Address Registers'' (BARs). La configuration des registres précise quelle portion de mémoire vidéo est adressable par le processeur, quelle est sa taille, sa position en mémoire vidéo, etc. Avant 2008, les BAR permettaient d’accéder à seulement 256 mégaoctets, pas plus. La gestion de la mémoire vidéo était alors difficile. Les échanges entre portion visible et invisible de la mémoire vidéo étaient complexes, demandaient d’exécuter des commandes spécifiques au GPU et autres. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''resizable bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. De nombreux fabricants de cartes graphiques commencent à incorporer cette technologie, qui demande quelques changements au niveau du système d'exploitation, des pilotes de périphériques et du matériel.
{{NavChapitre | book=Les cartes graphiques
| prev=La microarchitecture des processeurs de shaders
| prevText=La microarchitecture des processeurs de shaders
| next=La hiérarchie mémoire d'un GPU
| netxText=La hiérarchie mémoire d'un GPU
}}{{autocat}}
2wd36e573lnpf01ki229alhtbj59hvc
744052
744045
2025-06-03T16:46:51Z
Mewtow
31375
/* La IO-Memory Management Unit (IOMMU) */
744052
wikitext
text/x-wiki
Pour rappel, il existe deux types de cartes graphiques : les cartes dédiées et les cartes intégrées. Les '''cartes graphiques dédiées''' sont des cartes graphiques branchées sur des connecteurs/ports de la carte mère. A l'opposé, tous les processeurs modernes intègrent une carte graphique, appelée '''carte graphique intégrée''', ou encore '''IGP''' (''Integrated Graphic Processor''). En somme, les cartes dédiées sont opposées à celles intégrées dans les processeurs modernes.
La différence a un impact sur la mémoire vidéo. Les cartes graphiques dédiées ont souvent de la mémoire vidéo intégrée à la carte graphique. Il y a des exceptions, mais on en parlera plus tard. Les cartes graphiques intégrées au processeur n'ont pas de mémoire vidéo dédiée, vu qu'on ne peut pas intégrer beaucoup de mémoire vidéo dans un processeur. La conséquence est qu'il existe deux grandes manières d'organiser la mémoire à laquelle la carte graphique a accès.
* La première est celle de la '''mémoire vidéo dédiée''', à savoir que la carte graphique dispose de sa propre mémoire rien qu'à elle, séparée de la mémoire RAM de l'ordinateur. On fait alors la distinction entre ''RAM système'' et ''RAM vidéo''. Si les premières cartes graphiques n'avaient que quelques mégaoctets de RAM dédiée, elles disposent actuellement de plusieurs gigas-octets de RAM.
* A l'opposé, on trouve la '''mémoire unifiée''', avec une seule mémoire RAM est partagée entre le processeur et la carte graphique. Le terme "unifiée" sous-entend que l'on a unifié la mémoire vidéo et la mémoire système (la RAM).
[[File:Répartition de la mémoire entre RAM système et carte graphique.png|centre|vignette|upright=2.5|Répartition de la mémoire entre RAM système et carte graphique]]
Dans la grosse majorité des cas, les cartes vidéos dédiées ont une mémoire dédiée, alors que les cartes graphiques intégrées doivent utiliser la mémoire unifiée. Mais outre les cartes dédiées et intégrées, il faut aussi citer les cartes graphiques soudées sur la carte mère. Elles étaient utilisées sur les consoles de jeu vidéos assez anciennes, elles sont encore utilisées sur certains PC portables puissants, destinés aux ''gamers''. Pour ces dernières, il est possible d'utiliser aussi bien de la mémoire dédiée que de la mémoire unifiée. D'anciennes consoles de jeu avaient une carte graphique soudée sur la carte mère, qu'on peut facilement repérer à l’œil nu, avec une mémoire unifiée. C'est notamment le cas sur la Nintendo 64, pour ne citer qu'elle. D'autres avaient leur propre mémoire vidéo dédiée.
L'usage d'une carte vidéo dédiée se marie très bien avec une mémoire vidéo dédiée, mais il existe de nombreux cas où une carte vidéo dédiée est associée à de la mémoire unifiée. Comme exemple, la toute première carte graphique AGP, l'Intel 740, ne possédait pas de mémoire vidéo proprement dite, juste un simple ''framebuffer''. Tout le reste, texture comme géométrie, était placé en mémoire système et la carte graphique allait lire/écrire les données directement en mémoire RAM système ! Les performances sont généralement ridicules, pour des raisons très diverses, mais les cartes de ce type sont peu chères. Outre l'économie liée à l'absence de mémoire vidéo, les cartes graphiques de ce type sont peu puissantes, l'usage de la mémoire unifiée simplifie leur conception, etc. Par exemple, l'Intel 740 a eu un petit succès sur les ordinateurs d'entrée de gamme.
==Le partage de la mémoire unifiée==
Avec la mémoire unifiée, la quantité de mémoire système disponible pour la carte graphique est généralement réglable avec un réglage dans le BIOS. On peut ainsi choisir d'allouer 64, 128 ou 256 mégaoctets de mémoire système pour la carte vidéo, sur un ordinateur avec 4 gigaoctets de RAM. L'interprétation de ce réglage varie grandement selon les cartes mères ou l'IGP.
Pour les GPU les plus anciens, ce réglage implique que la RAM sélectionnée est réservée uniquement à la carte graphique, même si elle n'en utilise qu'une partie. La répartition entre mémoire vidéo et système est alors statique, fixée une fois pour toutes. Dans ce cas, la RAM allouée à la carte graphique est généralement petite par défaut. Les concepteurs de carte mère ne veulent pas qu'une trop quantité de RAM soit perdu et inutilisable pour les applications. Ils brident donc la carte vidéo et ne lui allouent que peu de RAM.
Heureusement, les GPU modernes sont plus souples. Ils fournissent deux réglages : une quantité de RAM minimale, totalement dédiée au GPU, et une quantité de RAM maximale que le GPU ne peut pas dépasser. Par exemple, il est possible de régler le GPU de manière à ce qu'il ait 64 mégaoctets rien que pour lui, mais qu'il puisse avoir accès à maximum 1 gigaoctet s'il en a besoin. Cela fait au total 960 mégaoctets (1024-64) qui peut être alloués au choix à la carte graphique ou au reste des programmes en cours d’exécution, selon les besoins. Il est possible d'allouer de grandes quantités de RAM au GPU, parfois la totalité de la mémoire système.
[[File:Partage de la mémoire unifiée entre CPU et GPU.png|centre|vignette|upright=2|Répartition de la mémoire entre RAM système et carte graphique]]
==Le partage de la mémoire système : la mémoire virtuelle des GPUs dédiés==
Après avoir vu la mémoire unifiée, voyons maintenant la mémoire dédiée. Intuitivement, on se dit que la carte graphique n'a accès qu'à la mémoire vidéo dédiée et ne peut pas lire de données dans la mémoire système, la RAM de l'ordinateur. Cependant, ce n'est pas le cas. Et ce pour plusieurs raisons. La raison principale est que des données doivent être copiées de la mémoire RAM vers la mémoire vidéo. Les copies en question se font souvent via ''Direct Memory Access'', ce qui fait que les GPU intègrent un contrôleur DMA dédié. Et ce contrôleur DMA lit des données en RAM système pour les copier en RAM vidéo.
La seconde raison est que les cartes graphiques intègrent presque toutes des technologies pour lire directement des données en RAM, sans forcément les copier en mémoire vidéo. Les technologies en question permettent à la carte graphique d'adresser plus de RAM qu'en a la mémoire vidéo. Par exemple, si la carte vidéo a 4 giga-octets de RAM, la carte graphique peut être capable d'en adresser 8 : 4 gigas en RAM vidéo, et 4 autres gigas en RAM système.
Les technologies de ce genre ressemblent beaucoup à la mémoire virtuelle des CPU, avec cependant quelques différences. La mémoire virtuelle permet à un processeur d'utiliser plus de RAM qu'il n'y en a d'installée dans l'ordinateur. Par exemple, elle permet au CPU de gérer 4 gigas de RAM sur un ordinateur qui n'en contient que trois, le gigaoctet de trop étant en réalité simulé par un fichier sur le disque dur. La technique est utilisée par tous les processeurs modernes. La mémoire virtuelle des GPUs dédiés fait la même chose, sauf que le surplus d'adresses n'est pas stockés sur le disque dur dans un fichier pagefile, mais est dans la RAM système. Pour le dire autrement, ces cartes dédiées peuvent utiliser la mémoire système si jamais la mémoire vidéo est pleine.
[[File:Mémoire virtuelle des cartes graphiques dédiées.png|centre|vignette|upright=2|Mémoire virtuelle des cartes graphiques dédiées]]
L'espace d'adressage est l'ensemble des adresses géré par le processeur ou la carte graphique. En théorie, l'espace d'adressage du processeur et de la carte graphique sont séparés, mais des standards comme l''''''Heterogeneous System Architecture''''' permettent au processeur et à une carte graphique de partager le même espace d'adressage. Une adresse mémoire est alors la même que ce soit pour le processeur ou la carte graphique.
{|class="wikitable"
|-
! !! Mémoire vidéo dédiée !! Mémoire vidéo unifiée
|-
! Sans HSA
| [[File:Desktop computer bus bandwidths.svg|400px|Desktop computer bus bandwidths]]
| [[File:Integrated graphics with distinct memory allocation.svg|400px|Integrated graphics with distinct memory allocation]]
|-
! Avec HSA
| [[File:HSA-enabled virtual memory with distinct graphics card.svg|400px|HSA-enabled virtual memory with distinct graphics card]]
| [[File:HSA-enabled integrated graphics.svg|400px|HSA-enabled integrated graphics]]
|}
===La ''IO-Memory Management Unit'' (IOMMU)===
Pour que la carte graphique ait accès à la mémoire système, elle intègre un circuit appelé la '''''Graphics address remapping table''''', abrévié en GART. Cela vaut aussi bien pour les cartes graphiques utilisant le bus AGP que pour celles en PCI-Express. La GART est techniquement une une ''Memory Management Unit'' (MMU), à savoir un circuit spécialisé qui prend en charge la mémoire virtuelle. La dite MMU étant intégrée dans un périphérique d'entrée-sortie (IO), ici la carte graphique, elle est appelée une IO-MMU (''Input Output-MMU'').
Le GPU utilise la technique dite de la pagination, à savoir que l'espace d'adressage est découpée en pages de taille fixe, généralement 4 kilo-octets. La traduction des adresses virtuelles en adresses physique se fait au niveau de la page. Une adresse est coupée en deux parts : un numéro de page, et la position de la donnée dans la page. La position dans la page ne change pas lors de la traduction d'adresse, mais le numéro de page est lui traduit. Le numéro de page virtuel est remplacé par un numéro de page physique lors de la traduction.
Pour remplacer le numéro de page virtuel en numéro physique, il faut utiliser une table de translation, appelée la '''table des pages''', qui associe un numéro de page logique à un numéro de page physique. Le système d'exploitation dispose de sa table des pages, qui n'est pas accesible au GPU. Par contre, le GPU dispose d'une sorte de mini-table des pages, qui contient les associations page virtuelle-physique utiles pour traiter les commandes GPU, et rien d'autre. En clair, une sorte de sous-ensemble de la table des pages de l'OS, mais spécifique au GPU. La mini-table des pages est gérée par le pilote de périphérique, qui remplit la mini-table des pages. La mini-table des pages est mémorisée dans une mémoire intégrée au GPU, et précisément dans la MMU.
===Historique de la mémoire virtuelle sur les GPU===
La technologie existait déjà sur certaines cartes graphiques au format PCI, mais la documentation est assez rare. La carte graphique NV1 de NVIDIA, leur toute première carte graphique, disposait déjà de ce système de mémoire virtuelle. La carte graphique communiquait avec le processeur grâce à la fonctionnalité ''Direct Memory Access'' et intégrait donc un contrôleur DMA. Le ''driver'' de la carte graphique programmait le contrôleur DMA en utilisant les adresses fournies par les applications/logiciels. Et il s'agissait d'adresses virtuelles, non d'adresses physiques en mémoire RAM. Pour résoudre ce problème, le contrôleur DMA intégrait une MMU, une unité de traduction d'adresse, qui traduisait les adresses virtuelles fournies par les applications en adresse physique en mémoire système.
: Le fonctionnement de cette IOMMU est décrite dans le brevet "US5758182A : DMA controller translates virtual I/O device address received directly from application program command to physical i/o device address of I/O device on device bus", des inventeurs David S. H. Rosenthal et Curtis Priem.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
La technologie s'est démocratisée avec le bus AGP, dont la fonctionnalité dite d'''AGP texturing'' permettait de lire ou écrire directement dans la mémoire RAM, sans passer par le processeur. D'ailleurs, la carte graphique Intel i740 n'avait pas de mémoire vidéo et se débrouillait uniquement avec la mémoire système.
L'arrivée du bus PCI-Express ne changea pas la donne, si ce n'est que le bus était plus rapide, ce qui améliorait les performances. Au début, seules les cartes graphiques PCI-Express d'entrée de gamme pouvaient accéder à certaines portions de la mémoire RAM grâce à des technologies adaptées, comme le TurboCache de NVIDIA ou l'HyperMemory d'AMD. Mais la technologie s'est aujourd'hui étendue. De nos jours, toutes les cartes vidéos modernes utilisent la RAM système en plus de la mémoire vidéo, mais seulement en dernier recours, soit quand la mémoire vidéo est quasiment pleine, soit pour faciliter les échanges de données avec le processeur. C'est typiquement le pilote de la carte graphique qui décide ce qui va dans la mémoire vidéo et la mémoire système, et il fait au mieux de manière à avoir les performances optimales.
==Les échanges entre processeur et mémoire vidéo==
Quand on charge un niveau de jeux vidéo, on doit notamment charger la scène, les textures, et d'autres choses dans la mémoire RAM, puis les envoyer à la carte graphique. Ce processus se fait d'une manière fortement différente selon que l'on a une mémoire unifiée ou une mémoire vidéo dédiée.
===Avec la mémoire unifiée===
Avec la mémoire unifié, les échanges de données entre processeur et carte graphique sont fortement simplifiés et aucune copie n'est nécessaire. La carte vidéo peut y accéder directement, en lisant leur position initiale en RAM. Une partie de la RAM est visible seulement pour le CPU, une autre seulement pour le GPU, le reste est partagé. Les échanges de données entre CPU et GPU se font en écrivant/lisant des données dans la RAM partagée entre CPU et GPU. Pas besoin de faire de copie d'une mémoire à une autre : la donnée a juste besoin d'être placée au bon endroit. Le chargement des textures, du tampon de commandes ou d'autres données du genre, est donc très rapide, presque instantané.
Par contre, le débit de la RAM unifiée est partagé entre la carte graphique et le processeur. Alors qu'avec une mémoire dédiée, tout le débit de la mémoire vidéo aurait été dédié au GPU, le CPU ayant quant à lui accès à tout le débit de la RAM système. De plus, le partage du débit n'est pas chose facile. Les deux se marchent sur les pieds. La carte graphique doit parfois attendre que le processeur lui laisse l'accès à la RAM et inversement. Divers circuits d'arbitrage s'occupent de répartir équitablement les accès à RAM entre les deux, mais cela ne permet que d'avoir un compromis imparfait qui peut réduire les performances. Le seul moyen pour réduire la casse est d'ajouter des mémoires caches entre le GPU et la RAM, mais l'efficacité des caches est relativement limitée pour le rendu 3D.
[[File:Echanges de données entre CPU et GPU avec une mémoire unifiée.png|centre|vignette|upright=2|Échanges de données entre CPU et GPU avec une mémoire unifiée]]
Un autre défaut survient sur les cartes dédiées à mémoire unifiée, par exemple l'Intel 740. Pour lire en mémoire RAM, elles doivent passer par l'intermédiaire du bus AGP, PCI ou PCI-Express. Et ce bus est très lent, bien plus que ne le serait une mémoire vidéo normale. Aussi, les performances sont exécrables. J'insiste sur le fait que l'on parle des cartes graphiques dédiées, mais pas des cartes graphiques soudées des consoles de jeu.
D'ailleurs, de telles cartes dédiées incorporent un ''framebuffer'' directement dans la carte graphique. Il n'y a pas le choix, le VDC de la carte graphique doit accéder à une mémoire suffisamment rapide pour alimenter l'écran. Ils ne peuvent pas prendre le risque d'aller lire la RAM, dont le temps de latence est élevé, et qui peut potentiellement être réservée par le processeur pendant l’affichage d'une image à l'écran.
===Avec une mémoire vidéo dédiée===
Avec une mémoire vidéo dédiée, on doit copier les données adéquates dans la mémoire vidéo, ce qui implique des transferts de données passant par le bus PCI-Express. Le processeur voit une partie de la mémoire vidéo, dans laquelle il peut lire ou écrire comme bon lui semble. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Il est possible pour le CPU de copier des données dans la portion invisible de la mémoire vidéo, mais cela se fait de manière indirecte en passant par le GPU d'abord. Il faut typiquement envoyer une commande spéciale au GPU, pour lui dire de charger une texture en mémoire vidéo, par exemple. Le GPU effectue alors une copie de la mémoire système vers la mémoire vidéo, en utilisant un contrôleur DMA intégré au GPU.
[[File:Interaction du GPU avec la mémoire vidéo et la RAM système sur une carte graphique dédiée.png|centre|vignette|upright=2|Échanges de données entre CPU et GPU avec une mémoire vidéo dédiée]]
La gestion de la mémoire vidéo est prise en charge par le pilote de la carte graphique, sur le processeur. Elle a tendance à allouer les textures et d'autres données de grande taille dans la mémoire vidéo invisible, le reste étant placé ailleurs. Les copies DMA vers la mémoire vidéo invisible sont adaptées à des copies de grosses données comme les textures, mais elles marchent mal pour des données assez petites. Or, les jeux vidéos ont tendance à générer à la volée de nombreuses données de petite taille, qu'il faut copier en mémoire vidéo. Et c'est sans compter sur des ressources du pilote de périphériques, qui doivent être copiées en mémoire vidéo, comme le tampon de commande ou d'autres ressources. Et celles-ci ne peuvent pas forcément être copiées dans la mémoire vidéo invisible. Si la mémoire vidéo visible par le CPU est trop petite, les données précédentes sont copiées dans la mémoire visible par le CPU, en mémoire système, mais leur accès par le GPU est alors très lent. Aussi, plus la portion visible de la mémoire vidéo est grande, plus simple est la gestion de la mémoire vidéo par le pilote graphique. Et de ce point de vue, les choses ont évolué récemment.
Pour accéder à un périphérique PCI-Express, il faut configurer des registres spécialisés, appelés les ''Base Address Registers'' (BARs). La configuration des registres précise quelle portion de mémoire vidéo est adressable par le processeur, quelle est sa taille, sa position en mémoire vidéo, etc. Avant 2008, les BAR permettaient d’accéder à seulement 256 mégaoctets, pas plus. La gestion de la mémoire vidéo était alors difficile. Les échanges entre portion visible et invisible de la mémoire vidéo étaient complexes, demandaient d’exécuter des commandes spécifiques au GPU et autres. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''resizable bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. De nombreux fabricants de cartes graphiques commencent à incorporer cette technologie, qui demande quelques changements au niveau du système d'exploitation, des pilotes de périphériques et du matériel.
{{NavChapitre | book=Les cartes graphiques
| prev=La microarchitecture des processeurs de shaders
| prevText=La microarchitecture des processeurs de shaders
| next=La hiérarchie mémoire d'un GPU
| netxText=La hiérarchie mémoire d'un GPU
}}{{autocat}}
kn4j3nom9b44fo6mwhk1bq3e5ir25ny
744053
744052
2025-06-03T16:53:10Z
Mewtow
31375
/* Le partage de la mémoire système : la mémoire virtuelle des GPUs dédiés */
744053
wikitext
text/x-wiki
Pour rappel, il existe deux types de cartes graphiques : les cartes dédiées et les cartes intégrées. Les '''cartes graphiques dédiées''' sont des cartes graphiques branchées sur des connecteurs/ports de la carte mère. A l'opposé, tous les processeurs modernes intègrent une carte graphique, appelée '''carte graphique intégrée''', ou encore '''IGP''' (''Integrated Graphic Processor''). En somme, les cartes dédiées sont opposées à celles intégrées dans les processeurs modernes.
La différence a un impact sur la mémoire vidéo. Les cartes graphiques dédiées ont souvent de la mémoire vidéo intégrée à la carte graphique. Il y a des exceptions, mais on en parlera plus tard. Les cartes graphiques intégrées au processeur n'ont pas de mémoire vidéo dédiée, vu qu'on ne peut pas intégrer beaucoup de mémoire vidéo dans un processeur. La conséquence est qu'il existe deux grandes manières d'organiser la mémoire à laquelle la carte graphique a accès.
* La première est celle de la '''mémoire vidéo dédiée''', à savoir que la carte graphique dispose de sa propre mémoire rien qu'à elle, séparée de la mémoire RAM de l'ordinateur. On fait alors la distinction entre ''RAM système'' et ''RAM vidéo''. Si les premières cartes graphiques n'avaient que quelques mégaoctets de RAM dédiée, elles disposent actuellement de plusieurs gigas-octets de RAM.
* A l'opposé, on trouve la '''mémoire unifiée''', avec une seule mémoire RAM est partagée entre le processeur et la carte graphique. Le terme "unifiée" sous-entend que l'on a unifié la mémoire vidéo et la mémoire système (la RAM).
[[File:Répartition de la mémoire entre RAM système et carte graphique.png|centre|vignette|upright=2.5|Répartition de la mémoire entre RAM système et carte graphique]]
Dans la grosse majorité des cas, les cartes vidéos dédiées ont une mémoire dédiée, alors que les cartes graphiques intégrées doivent utiliser la mémoire unifiée. Mais outre les cartes dédiées et intégrées, il faut aussi citer les cartes graphiques soudées sur la carte mère. Elles étaient utilisées sur les consoles de jeu vidéos assez anciennes, elles sont encore utilisées sur certains PC portables puissants, destinés aux ''gamers''. Pour ces dernières, il est possible d'utiliser aussi bien de la mémoire dédiée que de la mémoire unifiée. D'anciennes consoles de jeu avaient une carte graphique soudée sur la carte mère, qu'on peut facilement repérer à l’œil nu, avec une mémoire unifiée. C'est notamment le cas sur la Nintendo 64, pour ne citer qu'elle. D'autres avaient leur propre mémoire vidéo dédiée.
L'usage d'une carte vidéo dédiée se marie très bien avec une mémoire vidéo dédiée, mais il existe de nombreux cas où une carte vidéo dédiée est associée à de la mémoire unifiée. Comme exemple, la toute première carte graphique AGP, l'Intel 740, ne possédait pas de mémoire vidéo proprement dite, juste un simple ''framebuffer''. Tout le reste, texture comme géométrie, était placé en mémoire système et la carte graphique allait lire/écrire les données directement en mémoire RAM système ! Les performances sont généralement ridicules, pour des raisons très diverses, mais les cartes de ce type sont peu chères. Outre l'économie liée à l'absence de mémoire vidéo, les cartes graphiques de ce type sont peu puissantes, l'usage de la mémoire unifiée simplifie leur conception, etc. Par exemple, l'Intel 740 a eu un petit succès sur les ordinateurs d'entrée de gamme.
==Le partage de la mémoire unifiée==
Avec la mémoire unifiée, la quantité de mémoire système disponible pour la carte graphique est généralement réglable avec un réglage dans le BIOS. On peut ainsi choisir d'allouer 64, 128 ou 256 mégaoctets de mémoire système pour la carte vidéo, sur un ordinateur avec 4 gigaoctets de RAM. L'interprétation de ce réglage varie grandement selon les cartes mères ou l'IGP.
Pour les GPU les plus anciens, ce réglage implique que la RAM sélectionnée est réservée uniquement à la carte graphique, même si elle n'en utilise qu'une partie. La répartition entre mémoire vidéo et système est alors statique, fixée une fois pour toutes. Dans ce cas, la RAM allouée à la carte graphique est généralement petite par défaut. Les concepteurs de carte mère ne veulent pas qu'une trop quantité de RAM soit perdu et inutilisable pour les applications. Ils brident donc la carte vidéo et ne lui allouent que peu de RAM.
Heureusement, les GPU modernes sont plus souples. Ils fournissent deux réglages : une quantité de RAM minimale, totalement dédiée au GPU, et une quantité de RAM maximale que le GPU ne peut pas dépasser. Par exemple, il est possible de régler le GPU de manière à ce qu'il ait 64 mégaoctets rien que pour lui, mais qu'il puisse avoir accès à maximum 1 gigaoctet s'il en a besoin. Cela fait au total 960 mégaoctets (1024-64) qui peut être alloués au choix à la carte graphique ou au reste des programmes en cours d’exécution, selon les besoins. Il est possible d'allouer de grandes quantités de RAM au GPU, parfois la totalité de la mémoire système.
[[File:Partage de la mémoire unifiée entre CPU et GPU.png|centre|vignette|upright=2|Répartition de la mémoire entre RAM système et carte graphique]]
==Le partage de la mémoire système : la mémoire virtuelle des GPUs dédiés==
Après avoir vu la mémoire unifiée, voyons maintenant la mémoire dédiée. Intuitivement, on se dit que la carte graphique n'a accès qu'à la mémoire vidéo dédiée et ne peut pas lire de données dans la mémoire système, la RAM de l'ordinateur. Cependant, ce n'est pas le cas. Et ce pour plusieurs raisons. La raison principale est que des données doivent être copiées de la mémoire RAM vers la mémoire vidéo. Les copies en question se font souvent via ''Direct Memory Access'', ce qui fait que les GPU intègrent un contrôleur DMA dédié. Et ce contrôleur DMA lit des données en RAM système pour les copier en RAM vidéo.
La seconde raison est que les cartes graphiques intègrent presque toutes des technologies pour lire directement des données en RAM, sans forcément les copier en mémoire vidéo. Les technologies en question permettent à la carte graphique d'adresser plus de RAM qu'en a la mémoire vidéo. Par exemple, si la carte vidéo a 4 giga-octets de RAM, la carte graphique peut être capable d'en adresser 8 : 4 gigas en RAM vidéo, et 4 autres gigas en RAM système.
Les technologies de ce genre ressemblent beaucoup à la mémoire virtuelle des CPU, avec cependant quelques différences. La mémoire virtuelle permet à un processeur d'utiliser plus de RAM qu'il n'y en a d'installée dans l'ordinateur. Par exemple, elle permet au CPU de gérer 4 gigas de RAM sur un ordinateur qui n'en contient que trois, le gigaoctet de trop étant en réalité simulé par un fichier sur le disque dur. La technique est utilisée par tous les processeurs modernes. La mémoire virtuelle des GPUs dédiés fait la même chose, sauf que le surplus d'adresses n'est pas stockés sur le disque dur dans un fichier pagefile, mais est dans la RAM système. Pour le dire autrement, ces cartes dédiées peuvent utiliser la mémoire système si jamais la mémoire vidéo est pleine.
[[File:Mémoire virtuelle des cartes graphiques dédiées.png|centre|vignette|upright=2|Mémoire virtuelle des cartes graphiques dédiées]]
===La ''IO-Memory Management Unit'' (IOMMU)===
Pour que la carte graphique ait accès à la mémoire système, elle intègre un circuit appelé la '''''Graphics address remapping table''''', abrévié en GART. Cela vaut aussi bien pour les cartes graphiques utilisant le bus AGP que pour celles en PCI-Express. La GART est techniquement une une ''Memory Management Unit'' (MMU), à savoir un circuit spécialisé qui prend en charge la mémoire virtuelle. La dite MMU étant intégrée dans un périphérique d'entrée-sortie (IO), ici la carte graphique, elle est appelée une IO-MMU (''Input Output-MMU'').
Le GPU utilise la technique dite de la pagination, à savoir que l'espace d'adressage est découpée en pages de taille fixe, généralement 4 kilo-octets. La traduction des adresses virtuelles en adresses physique se fait au niveau de la page. Une adresse est coupée en deux parts : un numéro de page, et la position de la donnée dans la page. La position dans la page ne change pas lors de la traduction d'adresse, mais le numéro de page est lui traduit. Le numéro de page virtuel est remplacé par un numéro de page physique lors de la traduction.
Pour remplacer le numéro de page virtuel en numéro physique, il faut utiliser une table de translation, appelée la '''table des pages''', qui associe un numéro de page logique à un numéro de page physique. Le système d'exploitation dispose de sa table des pages, qui n'est pas accesible au GPU. Par contre, le GPU dispose d'une sorte de mini-table des pages, qui contient les associations page virtuelle-physique utiles pour traiter les commandes GPU, et rien d'autre. En clair, une sorte de sous-ensemble de la table des pages de l'OS, mais spécifique au GPU. La mini-table des pages est gérée par le pilote de périphérique, qui remplit la mini-table des pages. La mini-table des pages est mémorisée dans une mémoire intégrée au GPU, et précisément dans la MMU.
===Historique de la mémoire virtuelle sur les GPU===
La technologie existait déjà sur certaines cartes graphiques au format PCI, mais la documentation est assez rare. La carte graphique NV1 de NVIDIA, leur toute première carte graphique, disposait déjà de ce système de mémoire virtuelle. La carte graphique communiquait avec le processeur grâce à la fonctionnalité ''Direct Memory Access'' et intégrait donc un contrôleur DMA. Le ''driver'' de la carte graphique programmait le contrôleur DMA en utilisant les adresses fournies par les applications/logiciels. Et il s'agissait d'adresses virtuelles, non d'adresses physiques en mémoire RAM. Pour résoudre ce problème, le contrôleur DMA intégrait une MMU, une unité de traduction d'adresse, qui traduisait les adresses virtuelles fournies par les applications en adresse physique en mémoire système.
: Le fonctionnement de cette IOMMU est décrite dans le brevet "US5758182A : DMA controller translates virtual I/O device address received directly from application program command to physical i/o device address of I/O device on device bus", des inventeurs David S. H. Rosenthal et Curtis Priem.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
La technologie s'est démocratisée avec le bus AGP, dont la fonctionnalité dite d'''AGP texturing'' permettait de lire ou écrire directement dans la mémoire RAM, sans passer par le processeur. D'ailleurs, la carte graphique Intel i740 n'avait pas de mémoire vidéo et se débrouillait uniquement avec la mémoire système.
L'arrivée du bus PCI-Express ne changea pas la donne, si ce n'est que le bus était plus rapide, ce qui améliorait les performances. Au début, seules les cartes graphiques PCI-Express d'entrée de gamme pouvaient accéder à certaines portions de la mémoire RAM grâce à des technologies adaptées, comme le TurboCache de NVIDIA ou l'HyperMemory d'AMD. Mais la technologie s'est aujourd'hui étendue. De nos jours, toutes les cartes vidéos modernes utilisent la RAM système en plus de la mémoire vidéo, mais seulement en dernier recours, soit quand la mémoire vidéo est quasiment pleine, soit pour faciliter les échanges de données avec le processeur. C'est typiquement le pilote de la carte graphique qui décide ce qui va dans la mémoire vidéo et la mémoire système, et il fait au mieux de manière à avoir les performances optimales.
===L'espace d'adressage du CPU et du GPU===
L'espace d'adressage est l'ensemble des adresses géré par le processeur ou la carte graphique. En théorie, l'espace d'adressage du processeur et de la carte graphique sont séparés, mais des standards comme l''''''Heterogeneous System Architecture''''' permettent au processeur et à une carte graphique de partager le même espace d'adressage. Une adresse mémoire est alors la même que ce soit pour le processeur ou la carte graphique.
{|class="wikitable"
|-
! !! Mémoire vidéo dédiée !! Mémoire vidéo unifiée
|-
! Sans HSA
| [[File:Desktop computer bus bandwidths.svg|400px|Desktop computer bus bandwidths]]
| [[File:Integrated graphics with distinct memory allocation.svg|400px|Integrated graphics with distinct memory allocation]]
|-
! Avec HSA
| [[File:HSA-enabled virtual memory with distinct graphics card.svg|400px|HSA-enabled virtual memory with distinct graphics card]]
| [[File:HSA-enabled integrated graphics.svg|400px|HSA-enabled integrated graphics]]
|}
==Les échanges entre processeur et mémoire vidéo==
Quand on charge un niveau de jeux vidéo, on doit notamment charger la scène, les textures, et d'autres choses dans la mémoire RAM, puis les envoyer à la carte graphique. Ce processus se fait d'une manière fortement différente selon que l'on a une mémoire unifiée ou une mémoire vidéo dédiée.
===Avec la mémoire unifiée===
Avec la mémoire unifié, les échanges de données entre processeur et carte graphique sont fortement simplifiés et aucune copie n'est nécessaire. La carte vidéo peut y accéder directement, en lisant leur position initiale en RAM. Une partie de la RAM est visible seulement pour le CPU, une autre seulement pour le GPU, le reste est partagé. Les échanges de données entre CPU et GPU se font en écrivant/lisant des données dans la RAM partagée entre CPU et GPU. Pas besoin de faire de copie d'une mémoire à une autre : la donnée a juste besoin d'être placée au bon endroit. Le chargement des textures, du tampon de commandes ou d'autres données du genre, est donc très rapide, presque instantané.
Par contre, le débit de la RAM unifiée est partagé entre la carte graphique et le processeur. Alors qu'avec une mémoire dédiée, tout le débit de la mémoire vidéo aurait été dédié au GPU, le CPU ayant quant à lui accès à tout le débit de la RAM système. De plus, le partage du débit n'est pas chose facile. Les deux se marchent sur les pieds. La carte graphique doit parfois attendre que le processeur lui laisse l'accès à la RAM et inversement. Divers circuits d'arbitrage s'occupent de répartir équitablement les accès à RAM entre les deux, mais cela ne permet que d'avoir un compromis imparfait qui peut réduire les performances. Le seul moyen pour réduire la casse est d'ajouter des mémoires caches entre le GPU et la RAM, mais l'efficacité des caches est relativement limitée pour le rendu 3D.
[[File:Echanges de données entre CPU et GPU avec une mémoire unifiée.png|centre|vignette|upright=2|Échanges de données entre CPU et GPU avec une mémoire unifiée]]
Un autre défaut survient sur les cartes dédiées à mémoire unifiée, par exemple l'Intel 740. Pour lire en mémoire RAM, elles doivent passer par l'intermédiaire du bus AGP, PCI ou PCI-Express. Et ce bus est très lent, bien plus que ne le serait une mémoire vidéo normale. Aussi, les performances sont exécrables. J'insiste sur le fait que l'on parle des cartes graphiques dédiées, mais pas des cartes graphiques soudées des consoles de jeu.
D'ailleurs, de telles cartes dédiées incorporent un ''framebuffer'' directement dans la carte graphique. Il n'y a pas le choix, le VDC de la carte graphique doit accéder à une mémoire suffisamment rapide pour alimenter l'écran. Ils ne peuvent pas prendre le risque d'aller lire la RAM, dont le temps de latence est élevé, et qui peut potentiellement être réservée par le processeur pendant l’affichage d'une image à l'écran.
===Avec une mémoire vidéo dédiée===
Avec une mémoire vidéo dédiée, on doit copier les données adéquates dans la mémoire vidéo, ce qui implique des transferts de données passant par le bus PCI-Express. Le processeur voit une partie de la mémoire vidéo, dans laquelle il peut lire ou écrire comme bon lui semble. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Il est possible pour le CPU de copier des données dans la portion invisible de la mémoire vidéo, mais cela se fait de manière indirecte en passant par le GPU d'abord. Il faut typiquement envoyer une commande spéciale au GPU, pour lui dire de charger une texture en mémoire vidéo, par exemple. Le GPU effectue alors une copie de la mémoire système vers la mémoire vidéo, en utilisant un contrôleur DMA intégré au GPU.
[[File:Interaction du GPU avec la mémoire vidéo et la RAM système sur une carte graphique dédiée.png|centre|vignette|upright=2|Échanges de données entre CPU et GPU avec une mémoire vidéo dédiée]]
La gestion de la mémoire vidéo est prise en charge par le pilote de la carte graphique, sur le processeur. Elle a tendance à allouer les textures et d'autres données de grande taille dans la mémoire vidéo invisible, le reste étant placé ailleurs. Les copies DMA vers la mémoire vidéo invisible sont adaptées à des copies de grosses données comme les textures, mais elles marchent mal pour des données assez petites. Or, les jeux vidéos ont tendance à générer à la volée de nombreuses données de petite taille, qu'il faut copier en mémoire vidéo. Et c'est sans compter sur des ressources du pilote de périphériques, qui doivent être copiées en mémoire vidéo, comme le tampon de commande ou d'autres ressources. Et celles-ci ne peuvent pas forcément être copiées dans la mémoire vidéo invisible. Si la mémoire vidéo visible par le CPU est trop petite, les données précédentes sont copiées dans la mémoire visible par le CPU, en mémoire système, mais leur accès par le GPU est alors très lent. Aussi, plus la portion visible de la mémoire vidéo est grande, plus simple est la gestion de la mémoire vidéo par le pilote graphique. Et de ce point de vue, les choses ont évolué récemment.
Pour accéder à un périphérique PCI-Express, il faut configurer des registres spécialisés, appelés les ''Base Address Registers'' (BARs). La configuration des registres précise quelle portion de mémoire vidéo est adressable par le processeur, quelle est sa taille, sa position en mémoire vidéo, etc. Avant 2008, les BAR permettaient d’accéder à seulement 256 mégaoctets, pas plus. La gestion de la mémoire vidéo était alors difficile. Les échanges entre portion visible et invisible de la mémoire vidéo étaient complexes, demandaient d’exécuter des commandes spécifiques au GPU et autres. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''resizable bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. De nombreux fabricants de cartes graphiques commencent à incorporer cette technologie, qui demande quelques changements au niveau du système d'exploitation, des pilotes de périphériques et du matériel.
{{NavChapitre | book=Les cartes graphiques
| prev=La microarchitecture des processeurs de shaders
| prevText=La microarchitecture des processeurs de shaders
| next=La hiérarchie mémoire d'un GPU
| netxText=La hiérarchie mémoire d'un GPU
}}{{autocat}}
6d46u3cwhewt67t0pr004dexeoifz9m
744054
744053
2025-06-03T16:53:19Z
Mewtow
31375
/* L'espace d'adressage du CPU et du GPU */
744054
wikitext
text/x-wiki
Pour rappel, il existe deux types de cartes graphiques : les cartes dédiées et les cartes intégrées. Les '''cartes graphiques dédiées''' sont des cartes graphiques branchées sur des connecteurs/ports de la carte mère. A l'opposé, tous les processeurs modernes intègrent une carte graphique, appelée '''carte graphique intégrée''', ou encore '''IGP''' (''Integrated Graphic Processor''). En somme, les cartes dédiées sont opposées à celles intégrées dans les processeurs modernes.
La différence a un impact sur la mémoire vidéo. Les cartes graphiques dédiées ont souvent de la mémoire vidéo intégrée à la carte graphique. Il y a des exceptions, mais on en parlera plus tard. Les cartes graphiques intégrées au processeur n'ont pas de mémoire vidéo dédiée, vu qu'on ne peut pas intégrer beaucoup de mémoire vidéo dans un processeur. La conséquence est qu'il existe deux grandes manières d'organiser la mémoire à laquelle la carte graphique a accès.
* La première est celle de la '''mémoire vidéo dédiée''', à savoir que la carte graphique dispose de sa propre mémoire rien qu'à elle, séparée de la mémoire RAM de l'ordinateur. On fait alors la distinction entre ''RAM système'' et ''RAM vidéo''. Si les premières cartes graphiques n'avaient que quelques mégaoctets de RAM dédiée, elles disposent actuellement de plusieurs gigas-octets de RAM.
* A l'opposé, on trouve la '''mémoire unifiée''', avec une seule mémoire RAM est partagée entre le processeur et la carte graphique. Le terme "unifiée" sous-entend que l'on a unifié la mémoire vidéo et la mémoire système (la RAM).
[[File:Répartition de la mémoire entre RAM système et carte graphique.png|centre|vignette|upright=2.5|Répartition de la mémoire entre RAM système et carte graphique]]
Dans la grosse majorité des cas, les cartes vidéos dédiées ont une mémoire dédiée, alors que les cartes graphiques intégrées doivent utiliser la mémoire unifiée. Mais outre les cartes dédiées et intégrées, il faut aussi citer les cartes graphiques soudées sur la carte mère. Elles étaient utilisées sur les consoles de jeu vidéos assez anciennes, elles sont encore utilisées sur certains PC portables puissants, destinés aux ''gamers''. Pour ces dernières, il est possible d'utiliser aussi bien de la mémoire dédiée que de la mémoire unifiée. D'anciennes consoles de jeu avaient une carte graphique soudée sur la carte mère, qu'on peut facilement repérer à l’œil nu, avec une mémoire unifiée. C'est notamment le cas sur la Nintendo 64, pour ne citer qu'elle. D'autres avaient leur propre mémoire vidéo dédiée.
L'usage d'une carte vidéo dédiée se marie très bien avec une mémoire vidéo dédiée, mais il existe de nombreux cas où une carte vidéo dédiée est associée à de la mémoire unifiée. Comme exemple, la toute première carte graphique AGP, l'Intel 740, ne possédait pas de mémoire vidéo proprement dite, juste un simple ''framebuffer''. Tout le reste, texture comme géométrie, était placé en mémoire système et la carte graphique allait lire/écrire les données directement en mémoire RAM système ! Les performances sont généralement ridicules, pour des raisons très diverses, mais les cartes de ce type sont peu chères. Outre l'économie liée à l'absence de mémoire vidéo, les cartes graphiques de ce type sont peu puissantes, l'usage de la mémoire unifiée simplifie leur conception, etc. Par exemple, l'Intel 740 a eu un petit succès sur les ordinateurs d'entrée de gamme.
==Le partage de la mémoire unifiée==
Avec la mémoire unifiée, la quantité de mémoire système disponible pour la carte graphique est généralement réglable avec un réglage dans le BIOS. On peut ainsi choisir d'allouer 64, 128 ou 256 mégaoctets de mémoire système pour la carte vidéo, sur un ordinateur avec 4 gigaoctets de RAM. L'interprétation de ce réglage varie grandement selon les cartes mères ou l'IGP.
Pour les GPU les plus anciens, ce réglage implique que la RAM sélectionnée est réservée uniquement à la carte graphique, même si elle n'en utilise qu'une partie. La répartition entre mémoire vidéo et système est alors statique, fixée une fois pour toutes. Dans ce cas, la RAM allouée à la carte graphique est généralement petite par défaut. Les concepteurs de carte mère ne veulent pas qu'une trop quantité de RAM soit perdu et inutilisable pour les applications. Ils brident donc la carte vidéo et ne lui allouent que peu de RAM.
Heureusement, les GPU modernes sont plus souples. Ils fournissent deux réglages : une quantité de RAM minimale, totalement dédiée au GPU, et une quantité de RAM maximale que le GPU ne peut pas dépasser. Par exemple, il est possible de régler le GPU de manière à ce qu'il ait 64 mégaoctets rien que pour lui, mais qu'il puisse avoir accès à maximum 1 gigaoctet s'il en a besoin. Cela fait au total 960 mégaoctets (1024-64) qui peut être alloués au choix à la carte graphique ou au reste des programmes en cours d’exécution, selon les besoins. Il est possible d'allouer de grandes quantités de RAM au GPU, parfois la totalité de la mémoire système.
[[File:Partage de la mémoire unifiée entre CPU et GPU.png|centre|vignette|upright=2|Répartition de la mémoire entre RAM système et carte graphique]]
==Le partage de la mémoire système : la mémoire virtuelle des GPUs dédiés==
Après avoir vu la mémoire unifiée, voyons maintenant la mémoire dédiée. Intuitivement, on se dit que la carte graphique n'a accès qu'à la mémoire vidéo dédiée et ne peut pas lire de données dans la mémoire système, la RAM de l'ordinateur. Cependant, ce n'est pas le cas. Et ce pour plusieurs raisons. La raison principale est que des données doivent être copiées de la mémoire RAM vers la mémoire vidéo. Les copies en question se font souvent via ''Direct Memory Access'', ce qui fait que les GPU intègrent un contrôleur DMA dédié. Et ce contrôleur DMA lit des données en RAM système pour les copier en RAM vidéo.
La seconde raison est que les cartes graphiques intègrent presque toutes des technologies pour lire directement des données en RAM, sans forcément les copier en mémoire vidéo. Les technologies en question permettent à la carte graphique d'adresser plus de RAM qu'en a la mémoire vidéo. Par exemple, si la carte vidéo a 4 giga-octets de RAM, la carte graphique peut être capable d'en adresser 8 : 4 gigas en RAM vidéo, et 4 autres gigas en RAM système.
Les technologies de ce genre ressemblent beaucoup à la mémoire virtuelle des CPU, avec cependant quelques différences. La mémoire virtuelle permet à un processeur d'utiliser plus de RAM qu'il n'y en a d'installée dans l'ordinateur. Par exemple, elle permet au CPU de gérer 4 gigas de RAM sur un ordinateur qui n'en contient que trois, le gigaoctet de trop étant en réalité simulé par un fichier sur le disque dur. La technique est utilisée par tous les processeurs modernes. La mémoire virtuelle des GPUs dédiés fait la même chose, sauf que le surplus d'adresses n'est pas stockés sur le disque dur dans un fichier pagefile, mais est dans la RAM système. Pour le dire autrement, ces cartes dédiées peuvent utiliser la mémoire système si jamais la mémoire vidéo est pleine.
[[File:Mémoire virtuelle des cartes graphiques dédiées.png|centre|vignette|upright=2|Mémoire virtuelle des cartes graphiques dédiées]]
===La ''IO-Memory Management Unit'' (IOMMU)===
Pour que la carte graphique ait accès à la mémoire système, elle intègre un circuit appelé la '''''Graphics address remapping table''''', abrévié en GART. Cela vaut aussi bien pour les cartes graphiques utilisant le bus AGP que pour celles en PCI-Express. La GART est techniquement une une ''Memory Management Unit'' (MMU), à savoir un circuit spécialisé qui prend en charge la mémoire virtuelle. La dite MMU étant intégrée dans un périphérique d'entrée-sortie (IO), ici la carte graphique, elle est appelée une IO-MMU (''Input Output-MMU'').
Le GPU utilise la technique dite de la pagination, à savoir que l'espace d'adressage est découpée en pages de taille fixe, généralement 4 kilo-octets. La traduction des adresses virtuelles en adresses physique se fait au niveau de la page. Une adresse est coupée en deux parts : un numéro de page, et la position de la donnée dans la page. La position dans la page ne change pas lors de la traduction d'adresse, mais le numéro de page est lui traduit. Le numéro de page virtuel est remplacé par un numéro de page physique lors de la traduction.
Pour remplacer le numéro de page virtuel en numéro physique, il faut utiliser une table de translation, appelée la '''table des pages''', qui associe un numéro de page logique à un numéro de page physique. Le système d'exploitation dispose de sa table des pages, qui n'est pas accesible au GPU. Par contre, le GPU dispose d'une sorte de mini-table des pages, qui contient les associations page virtuelle-physique utiles pour traiter les commandes GPU, et rien d'autre. En clair, une sorte de sous-ensemble de la table des pages de l'OS, mais spécifique au GPU. La mini-table des pages est gérée par le pilote de périphérique, qui remplit la mini-table des pages. La mini-table des pages est mémorisée dans une mémoire intégrée au GPU, et précisément dans la MMU.
===Historique de la mémoire virtuelle sur les GPU===
La technologie existait déjà sur certaines cartes graphiques au format PCI, mais la documentation est assez rare. La carte graphique NV1 de NVIDIA, leur toute première carte graphique, disposait déjà de ce système de mémoire virtuelle. La carte graphique communiquait avec le processeur grâce à la fonctionnalité ''Direct Memory Access'' et intégrait donc un contrôleur DMA. Le ''driver'' de la carte graphique programmait le contrôleur DMA en utilisant les adresses fournies par les applications/logiciels. Et il s'agissait d'adresses virtuelles, non d'adresses physiques en mémoire RAM. Pour résoudre ce problème, le contrôleur DMA intégrait une MMU, une unité de traduction d'adresse, qui traduisait les adresses virtuelles fournies par les applications en adresse physique en mémoire système.
: Le fonctionnement de cette IOMMU est décrite dans le brevet "US5758182A : DMA controller translates virtual I/O device address received directly from application program command to physical i/o device address of I/O device on device bus", des inventeurs David S. H. Rosenthal et Curtis Priem.
[[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture du GPU NV1 de NVIDIA]]
La technologie s'est démocratisée avec le bus AGP, dont la fonctionnalité dite d'''AGP texturing'' permettait de lire ou écrire directement dans la mémoire RAM, sans passer par le processeur. D'ailleurs, la carte graphique Intel i740 n'avait pas de mémoire vidéo et se débrouillait uniquement avec la mémoire système.
L'arrivée du bus PCI-Express ne changea pas la donne, si ce n'est que le bus était plus rapide, ce qui améliorait les performances. Au début, seules les cartes graphiques PCI-Express d'entrée de gamme pouvaient accéder à certaines portions de la mémoire RAM grâce à des technologies adaptées, comme le TurboCache de NVIDIA ou l'HyperMemory d'AMD. Mais la technologie s'est aujourd'hui étendue. De nos jours, toutes les cartes vidéos modernes utilisent la RAM système en plus de la mémoire vidéo, mais seulement en dernier recours, soit quand la mémoire vidéo est quasiment pleine, soit pour faciliter les échanges de données avec le processeur. C'est typiquement le pilote de la carte graphique qui décide ce qui va dans la mémoire vidéo et la mémoire système, et il fait au mieux de manière à avoir les performances optimales.
===L'espace d'adressage du CPU et du GPU===
L'espace d'adressage est l'ensemble des adresses géré par le processeur ou la carte graphique. En général, l'espace d'adressage du processeur et de la carte graphique sont séparés, mais des standards comme l''''''Heterogeneous System Architecture''''' permettent au processeur et à une carte graphique de partager le même espace d'adressage. Une adresse mémoire est alors la même que ce soit pour le processeur ou la carte graphique.
{|class="wikitable"
|-
! !! Mémoire vidéo dédiée !! Mémoire vidéo unifiée
|-
! Sans HSA
| [[File:Desktop computer bus bandwidths.svg|400px|Desktop computer bus bandwidths]]
| [[File:Integrated graphics with distinct memory allocation.svg|400px|Integrated graphics with distinct memory allocation]]
|-
! Avec HSA
| [[File:HSA-enabled virtual memory with distinct graphics card.svg|400px|HSA-enabled virtual memory with distinct graphics card]]
| [[File:HSA-enabled integrated graphics.svg|400px|HSA-enabled integrated graphics]]
|}
==Les échanges entre processeur et mémoire vidéo==
Quand on charge un niveau de jeux vidéo, on doit notamment charger la scène, les textures, et d'autres choses dans la mémoire RAM, puis les envoyer à la carte graphique. Ce processus se fait d'une manière fortement différente selon que l'on a une mémoire unifiée ou une mémoire vidéo dédiée.
===Avec la mémoire unifiée===
Avec la mémoire unifié, les échanges de données entre processeur et carte graphique sont fortement simplifiés et aucune copie n'est nécessaire. La carte vidéo peut y accéder directement, en lisant leur position initiale en RAM. Une partie de la RAM est visible seulement pour le CPU, une autre seulement pour le GPU, le reste est partagé. Les échanges de données entre CPU et GPU se font en écrivant/lisant des données dans la RAM partagée entre CPU et GPU. Pas besoin de faire de copie d'une mémoire à une autre : la donnée a juste besoin d'être placée au bon endroit. Le chargement des textures, du tampon de commandes ou d'autres données du genre, est donc très rapide, presque instantané.
Par contre, le débit de la RAM unifiée est partagé entre la carte graphique et le processeur. Alors qu'avec une mémoire dédiée, tout le débit de la mémoire vidéo aurait été dédié au GPU, le CPU ayant quant à lui accès à tout le débit de la RAM système. De plus, le partage du débit n'est pas chose facile. Les deux se marchent sur les pieds. La carte graphique doit parfois attendre que le processeur lui laisse l'accès à la RAM et inversement. Divers circuits d'arbitrage s'occupent de répartir équitablement les accès à RAM entre les deux, mais cela ne permet que d'avoir un compromis imparfait qui peut réduire les performances. Le seul moyen pour réduire la casse est d'ajouter des mémoires caches entre le GPU et la RAM, mais l'efficacité des caches est relativement limitée pour le rendu 3D.
[[File:Echanges de données entre CPU et GPU avec une mémoire unifiée.png|centre|vignette|upright=2|Échanges de données entre CPU et GPU avec une mémoire unifiée]]
Un autre défaut survient sur les cartes dédiées à mémoire unifiée, par exemple l'Intel 740. Pour lire en mémoire RAM, elles doivent passer par l'intermédiaire du bus AGP, PCI ou PCI-Express. Et ce bus est très lent, bien plus que ne le serait une mémoire vidéo normale. Aussi, les performances sont exécrables. J'insiste sur le fait que l'on parle des cartes graphiques dédiées, mais pas des cartes graphiques soudées des consoles de jeu.
D'ailleurs, de telles cartes dédiées incorporent un ''framebuffer'' directement dans la carte graphique. Il n'y a pas le choix, le VDC de la carte graphique doit accéder à une mémoire suffisamment rapide pour alimenter l'écran. Ils ne peuvent pas prendre le risque d'aller lire la RAM, dont le temps de latence est élevé, et qui peut potentiellement être réservée par le processeur pendant l’affichage d'une image à l'écran.
===Avec une mémoire vidéo dédiée===
Avec une mémoire vidéo dédiée, on doit copier les données adéquates dans la mémoire vidéo, ce qui implique des transferts de données passant par le bus PCI-Express. Le processeur voit une partie de la mémoire vidéo, dans laquelle il peut lire ou écrire comme bon lui semble. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Il est possible pour le CPU de copier des données dans la portion invisible de la mémoire vidéo, mais cela se fait de manière indirecte en passant par le GPU d'abord. Il faut typiquement envoyer une commande spéciale au GPU, pour lui dire de charger une texture en mémoire vidéo, par exemple. Le GPU effectue alors une copie de la mémoire système vers la mémoire vidéo, en utilisant un contrôleur DMA intégré au GPU.
[[File:Interaction du GPU avec la mémoire vidéo et la RAM système sur une carte graphique dédiée.png|centre|vignette|upright=2|Échanges de données entre CPU et GPU avec une mémoire vidéo dédiée]]
La gestion de la mémoire vidéo est prise en charge par le pilote de la carte graphique, sur le processeur. Elle a tendance à allouer les textures et d'autres données de grande taille dans la mémoire vidéo invisible, le reste étant placé ailleurs. Les copies DMA vers la mémoire vidéo invisible sont adaptées à des copies de grosses données comme les textures, mais elles marchent mal pour des données assez petites. Or, les jeux vidéos ont tendance à générer à la volée de nombreuses données de petite taille, qu'il faut copier en mémoire vidéo. Et c'est sans compter sur des ressources du pilote de périphériques, qui doivent être copiées en mémoire vidéo, comme le tampon de commande ou d'autres ressources. Et celles-ci ne peuvent pas forcément être copiées dans la mémoire vidéo invisible. Si la mémoire vidéo visible par le CPU est trop petite, les données précédentes sont copiées dans la mémoire visible par le CPU, en mémoire système, mais leur accès par le GPU est alors très lent. Aussi, plus la portion visible de la mémoire vidéo est grande, plus simple est la gestion de la mémoire vidéo par le pilote graphique. Et de ce point de vue, les choses ont évolué récemment.
Pour accéder à un périphérique PCI-Express, il faut configurer des registres spécialisés, appelés les ''Base Address Registers'' (BARs). La configuration des registres précise quelle portion de mémoire vidéo est adressable par le processeur, quelle est sa taille, sa position en mémoire vidéo, etc. Avant 2008, les BAR permettaient d’accéder à seulement 256 mégaoctets, pas plus. La gestion de la mémoire vidéo était alors difficile. Les échanges entre portion visible et invisible de la mémoire vidéo étaient complexes, demandaient d’exécuter des commandes spécifiques au GPU et autres. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''resizable bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. De nombreux fabricants de cartes graphiques commencent à incorporer cette technologie, qui demande quelques changements au niveau du système d'exploitation, des pilotes de périphériques et du matériel.
{{NavChapitre | book=Les cartes graphiques
| prev=La microarchitecture des processeurs de shaders
| prevText=La microarchitecture des processeurs de shaders
| next=La hiérarchie mémoire d'un GPU
| netxText=La hiérarchie mémoire d'un GPU
}}{{autocat}}
dboeqjmxsb1hya9dbz479k3fygw6a0u
Fonctionnement d'un ordinateur/L'unité de chargement et le program counter
0
80691
744058
743108
2025-06-03T18:38:48Z
Mewtow
31375
/* Le Zero-overhead looping */
744058
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
===La ''prefetch input queue''===
Avec un pipeline, plusieurs instructions sont présentes en même temps dans le pipeline. Et cela fait que des accès mémoires simultanés peuvent avoir lieu. Pour comprendre pourquoi, regardons dans quels étages du pipeline il peut y avoir un accès mémoire. Le premier, assez évident, est celui dédié aux accès mémoire, lors de l’exécution d'une instruction. Si une instruction effectue un accès mémoire, c'est cet étage qui le prend en charge. Il n'est pas systématique, seules certaines instructions l'utilisent. Par contre, un second étage utilise systématiquement un accès mémoire : le chargement de l'instruction depuis la mémoire ou le cache.
En clair, il n'est pas rare qu'on ait accès simultané à la mémoire : un pour charger l'instruction, un autre pour charger la donnée. Pour éviter cela, une solution simple consiste à séparer les voies d'accès aux instructions et aux données, de manière à autoriser des accès simultanés. Dans le cas le plus fréquent, les processeurs disposent d'un cache, et la solution est alors évidente : le cache L1 est séparé en deux, avec un cache d’instruction séparé du cache L1 de données. Les accès concurrents à la mémoire ne sont plus un problème. Sur les processeurs ne disposant pas de mémoire cache, la solution la plus est d'utiliser une architecture Harvard. Dans tous les cas, il faut utiliser une architecture harvard, ou harvard modifiée (caches séparés).
Sur les architectures Von Neumann, il est possible de limiter la casse en utilisant une optimisation appelée la ''prefetch input queue'', vue dans le chapitre sur le chargement des instructions. l'idée est que le processeur précharge les instructions quand la mémoire est libre, dans une mémoire tampon située avant l'unité de décodage. Si un accès aux données a lieu, le processeur peut décoder les instructions préchargées, en les piochant dans la mémoire tampon, sans accéder à la RAM ou au cache.
Pour implémenter le préchargement d'instruction, le registre d'instruction est remplacé par une mémoire FIFO appelée la '''''Prefetch input queue'''''. On peut la voir comme un registre d'instruction sous stéroïde, capable de mémoriser plusieurs instructions consécutives. Les instructions sont chargées dans la ''Prefetch input queue'' et y attendent que le processeur les lise et les décode. Les instructions y sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser cette méthode était le 8086, un processeur d'Intel de jeu d'instruction x86. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permattait que de précharger 4 instructions. 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 voir dans ce qui suit.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Cette détection ne peut se faire qu'une fois le branchement décodé : on ne sait pas si l'instruction est un branchement ou autre chose tant que le décodage n'a pas eu lieu, par définition. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
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é.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
3n8559z9ofqeorlcqy087zvwixtqb5c
744059
744058
2025-06-03T18:45:02Z
Mewtow
31375
/* Les optimisations du chargement des instructions */
744059
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement. L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne part pas du principe que la mémoire a un débit binaire important. L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Évidemment, les instructions préchargées sont accumulées dans une mémoire FIFO appelée la '''''prefetch input queue''''', elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
La ''prefetch input queue'' a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peutr en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
Pour implémenter le préchargement d'instruction, le registre d'instruction est remplacé par une mémoire FIFO appelée la '''''Prefetch input queue'''''. On peut la voir comme un registre d'instruction sous stéroïde, capable de mémoriser plusieurs instructions consécutives. Les instructions sont chargées dans la ''Prefetch input queue'' et y attendent que le processeur les lise et les décode. Les instructions y sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser cette méthode était le 8086, un processeur d'Intel de jeu d'instruction x86. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permattait que de précharger 4 instructions. 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 voir dans ce qui suit.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Cette détection ne peut se faire qu'une fois le branchement décodé : on ne sait pas si l'instruction est un branchement ou autre chose tant que le décodage n'a pas eu lieu, par définition. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
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é.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
bezoq4ld7y16mmcptxruvcvvi1uzxbn
744060
744059
2025-06-03T18:46:36Z
Mewtow
31375
/* La prefetch input queue */
744060
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une optimisation matérielle qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement. L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne part pas du principe que la mémoire a un débit binaire important, on ne charge pas plusieurs instructions à la fois. Les instructions sont préchargées séquentiellement, un octet à la fois. Évidemment, les instructions préchargées sont accumulées dans une mémoire FIFO appelée la '''''prefetch input queue''''', elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peutr en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
Pour implémenter le préchargement d'instruction, le registre d'instruction est remplacé par une mémoire FIFO appelée la '''''Prefetch input queue'''''. On peut la voir comme un registre d'instruction sous stéroïde, capable de mémoriser plusieurs instructions consécutives. Les instructions sont chargées dans la ''Prefetch input queue'' et y attendent que le processeur les lise et les décode. Les instructions y sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser cette méthode était le 8086, un processeur d'Intel de jeu d'instruction x86. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permattait que de précharger 4 instructions. 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 voir dans ce qui suit.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Cette détection ne peut se faire qu'une fois le branchement décodé : on ne sait pas si l'instruction est un branchement ou autre chose tant que le décodage n'a pas eu lieu, par définition. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
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é.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
fi2iwpl6xji8eutkiarejqxzmj3xtcp
744061
744060
2025-06-03T18:48:36Z
Mewtow
31375
/* La prefetch input queue */
744061
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une optimisation matérielle qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. Le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction. La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peutr en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
Pour implémenter le préchargement d'instruction, le registre d'instruction est remplacé par une mémoire FIFO appelée la '''''Prefetch input queue'''''. On peut la voir comme un registre d'instruction sous stéroïde, capable de mémoriser plusieurs instructions consécutives. Les instructions sont chargées dans la ''Prefetch input queue'' et y attendent que le processeur les lise et les décode. Les instructions y sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser cette méthode était le 8086, un processeur d'Intel de jeu d'instruction x86. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permattait que de précharger 4 instructions. 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 voir dans ce qui suit.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Cette détection ne peut se faire qu'une fois le branchement décodé : on ne sait pas si l'instruction est un branchement ou autre chose tant que le décodage n'a pas eu lieu, par définition. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
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é.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
2cultaozuhamxcjzpyiuj1v1j124j13
744062
744061
2025-06-03T18:50:21Z
Mewtow
31375
/* La prefetch input queue */
744062
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une optimisation matérielle qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. Le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction. La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peutr en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''.
Pour implémenter le préchargement d'instruction, le registre d'instruction est remplacé par une mémoire FIFO appelée la '''''Prefetch input queue'''''. On peut la voir comme un registre d'instruction sous stéroïde, capable de mémoriser plusieurs instructions consécutives. Les instructions sont chargées dans la ''Prefetch input queue'' et y attendent que le processeur les lise et les décode. Les instructions y sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser cette méthode était le 8086, un processeur d'Intel de jeu d'instruction x86. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permattait que de précharger 4 instructions. 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 voir dans ce qui suit.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Cette détection ne peut se faire qu'une fois le branchement décodé : on ne sait pas si l'instruction est un branchement ou autre chose tant que le décodage n'a pas eu lieu, par définition. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
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é.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
ch0joxm7ppqk23jk2crzi01sbt6nozj
744063
744062
2025-06-03T18:51:39Z
Mewtow
31375
/* La prefetch input queue */
744063
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une optimisation matérielle qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. Le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction. La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peutr en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''.
Le premier processeur commercial grand public à utiliser cette méthode était le 8086, un processeur d'Intel de jeu d'instruction x86. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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 voir dans ce qui suit.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Cette détection ne peut se faire qu'une fois le branchement décodé : on ne sait pas si l'instruction est un branchement ou autre chose tant que le décodage n'a pas eu lieu, par définition. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
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é.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
rg33t8sc0vpy6t2r62t5ipkntsiwgoj
744068
744063
2025-06-03T19:01:23Z
Mewtow
31375
/* La prefetch input queue */
744068
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une optimisation matérielle qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. Le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction. La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peutr en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Sur le 8086 et le 8088, la plupart des instructions faisaient deux octets, alors que la mémoire RAM ne permettait que de charger un octet à la fois. Le décodeur devait attendre que les deux octets d'une instruction soient disponibles dans la ''Prefetch input queue''. Pour cela, un circuit appelé le ''loader'' fournissait deux signaux au décodeur d'instruction : l'un indiquant que le premier octet est déjà chargé, l'autre indiquant que le second octet est disponible. Le décodeur, quant à lui, envoyait le signal ''Run Next Instruction'' pour lire une instruction : elle était alors dépilée de la ''Prefetch input queue''.
Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'' un cycle en avance, un cyucle avant que l'instruction en cours ne termine. Le résultat est que le ''loader'' réagissait en préchargeant l'instruction suivante. 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 ''Prefetch input queue''.
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''.
Le premier processeur commercial grand public à utiliser cette méthode était le 8086, un processeur d'Intel de jeu d'instruction x86. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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 voir dans ce qui suit.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Cette détection ne peut se faire qu'une fois le branchement décodé : on ne sait pas si l'instruction est un branchement ou autre chose tant que le décodage n'a pas eu lieu, par définition. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
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é.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
bz8z4zwhl1miqzft5fufs2869ag6hsq
744069
744068
2025-06-03T19:04:09Z
Mewtow
31375
/* La prefetch input queue */
744069
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une optimisation matérielle qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. Le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction. La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peutr en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Sur le 8086 et le 8088, la plupart des instructions faisaient deux octets, alors que la mémoire RAM ne permettait que de charger un octet à la fois. Le décodeur devait attendre que les deux octets d'une instruction soient disponibles dans la ''Prefetch input queue''. Pour cela, un circuit appelé le ''loader'' fournissait deux signaux au décodeur d'instruction : l'un indiquant que le premier octet est déjà chargé, l'autre indiquant que le second octet est disponible. Le décodeur, quant à lui, envoyait le signal ''Run Next Instruction'' pour lire une instruction : elle était alors dépilée de la ''Prefetch input queue''.
Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'' un cycle en avance, un cyucle avant que l'instruction en cours ne termine. Le résultat est que le ''loader'' réagissait en préchargeant l'instruction suivante. 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 ''Prefetch input queue''.
Il faut noter que les instructions x86 sont de longueur variables. Pour les gérer, le décodeur pouvait lire une instruction en plusieurs fois dans la ''Prefetch input queue''. Les deux premiers octets sont lus depuis la ''Prefetch input queue'', et sont alors décodés. Le décodeur détermine alors la taille de l'instruction. Si l'instruction fait exactement deux octets, elle est exécutée directement. Sinon, les octets manquants sont lus depuis la ''Prefetch input queue''.
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''.
Le premier processeur commercial grand public à utiliser cette méthode était le 8086, un processeur d'Intel de jeu d'instruction x86. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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 voir dans ce qui suit.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Cette détection ne peut se faire qu'une fois le branchement décodé : on ne sait pas si l'instruction est un branchement ou autre chose tant que le décodage n'a pas eu lieu, par définition. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
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é.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
qbcwwd91j8uy9n9sfrjwambv2rvttgs
744070
744069
2025-06-03T19:13:23Z
Mewtow
31375
/* La prefetch input queue */
744070
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une optimisation matérielle qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. Le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction. La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peutr en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la 'prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Sur le 8086 et le 8088, la plupart des instructions faisaient deux octets, alors que la mémoire RAM ne permettait que de charger un octet à la fois. Le décodeur devait attendre que les deux octets d'une instruction soient disponibles dans la ''Prefetch input queue''. Pour cela, un circuit appelé le ''loader'' fournissait deux signaux au décodeur d'instruction : l'un indiquant que le premier octet est déjà chargé, l'autre indiquant que le second octet est disponible. Le décodeur, quant à lui, envoyait le signal ''Run Next Instruction'' pour lire une instruction : elle était alors dépilée de la ''Prefetch input queue''.
Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'' un cycle en avance, un cucle avant que l'instruction en cours ne termine. Le résultat est que le ''loader'' réagissait en préchargeant l'instruction suivante. 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 ''Prefetch input queue''.
Il faut noter que les instructions x86 sont de longueur variables. Pour les gérer, le décodeur pouvait lire une instruction en plusieurs fois dans la ''Prefetch input queue''. Les deux premiers octets sont lus depuis la ''Prefetch input queue'', et sont alors décodés. Le décodeur détermine alors la taille de l'instruction. Si l'instruction fait exactement deux octets, elle est exécutée directement. Sinon, les octets manquants sont lus depuis la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un registre dédié. Et pour ne pas faire doublon, ce registre remplace le ''program counter''. Après tout, le registre n'est qu'un ''program counter'' ayant pris une avance de quelques cycles d'horloge, qui a été incrémenté en avance. Et cela ne pose pas de problèmes, sauf dans le cas des branchements.
Le décodeur d'instruction dispose d'autres micro-opérations qui agissent sur la ''Prefetch input queue'', trois au total. La première stoppe le préchargement des instructions dans la ''Prefetch input queue''. La seconde vide la ''Prefetch input queue'', elle invalide les instructions préchargées. La troisième permet de reconstituer le ''program counter''. Elles servent à gérer l'exécution des branchements, comme on le verra plus bas.
Le premier processeur commercial grand public à utiliser cette méthode était le 8086, un processeur d'Intel de jeu d'instruction x86. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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 voir dans ce qui suit.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Cette détection ne peut se faire qu'une fois le branchement décodé : on ne sait pas si l'instruction est un branchement ou autre chose tant que le décodage n'a pas eu lieu, par définition. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
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é.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
p1h2hxtrwy3t8uqmrke8x0geosr75c2
744075
744070
2025-06-03T19:17:00Z
Mewtow
31375
/* La prefetch input queue */
744075
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une optimisation matérielle qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. Le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction. La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la 'prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
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. Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions.
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable. Les instructions de ces processeurs ont une taille qui varie entre deux octets et 6 octets. La ''prefetch input queue'' était optimisée pour lire des instructions de deux octets, avec quelques mécanismes pour gérer les instructions plus longues. Il faut dire que les instructions les plus courantes faisaient deux octets, respectivement un octet d'opcode et un octet Mod/RM pour préciser le mode d'adressage.
Si les instructions faisaient deux octets ou plus, La mémoire RAM ne permettait que de charger un octet à la fois. Le décodeur devait attendre que les deux octets d'une instruction soient disponibles dans la ''Prefetch input queue''. Pour cela, un circuit appelé le ''loader'' fournissait deux signaux au décodeur d'instruction : l'un indiquant que le premier octet est déjà chargé, l'autre indiquant que le second octet est disponible. Le décodeur, quant à lui, envoyait le signal ''Run Next Instruction'' pour lire une instruction : elle était alors dépilée de la ''Prefetch input queue''.
Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'' un cycle en avance, un cucle avant que l'instruction en cours ne termine. Le résultat est que le ''loader'' réagissait en préchargeant l'instruction suivante. 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 ''Prefetch input queue''.
Il faut noter que les instructions x86 sont de longueur variables. Pour les gérer, le décodeur pouvait lire une instruction en plusieurs fois dans la ''Prefetch input queue''. Les deux premiers octets sont lus depuis la ''Prefetch input queue'', et sont alors décodés. Le décodeur détermine alors la taille de l'instruction. Si l'instruction fait exactement deux octets, elle est exécutée directement. Sinon, les octets manquants sont lus depuis la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un registre dédié. Et pour ne pas faire doublon, ce registre remplace le ''program counter''. Après tout, le registre n'est qu'un ''program counter'' ayant pris une avance de quelques cycles d'horloge, qui a été incrémenté en avance. Et cela ne pose pas de problèmes, sauf dans le cas des branchements.
Le décodeur d'instruction dispose d'autres micro-opérations qui agissent sur la ''Prefetch input queue'', trois au total. La première stoppe le préchargement des instructions dans la ''Prefetch input queue''. La seconde vide la ''Prefetch input queue'', elle invalide les instructions préchargées. La troisième permet de reconstituer le ''program counter''. Elles servent à gérer l'exécution des branchements, comme on le verra plus bas.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Cette détection ne peut se faire qu'une fois le branchement décodé : on ne sait pas si l'instruction est un branchement ou autre chose tant que le décodage n'a pas eu lieu, par définition. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
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é.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
dufpptlx4qwzxmdyxg14b0f40zra8p0
744076
744075
2025-06-03T19:19:26Z
Mewtow
31375
/* La prefetch input queue */
744076
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une optimisation matérielle qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. Le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction. La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la 'prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
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. Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions.
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable. Les instructions de ces processeurs ont une taille qui varie entre deux octets et 6 octets. La ''prefetch input queue'' était optimisée pour lire des instructions de deux octets, avec quelques mécanismes pour gérer les instructions plus longues. Il faut dire que les instructions les plus courantes faisaient deux octets, respectivement un octet d'opcode et un octet Mod/RM pour préciser le mode d'adressage.
Si les instructions faisaient deux octets ou plus, La mémoire RAM ne permettait que de charger un octet à la fois. Le décodeur devait attendre que les deux octets d'une instruction soient disponibles dans la ''Prefetch input queue''. Pour cela, un circuit appelé le ''loader'' fournissait deux signaux au décodeur d'instruction : l'un indiquant que le premier octet est déjà chargé, l'autre indiquant que le second octet est disponible.
Pour gérer les instructions de plus de deux octets, le décodeur pouvait lire une instruction en plusieurs fois dans la ''Prefetch input queue''. Les deux premiers octets sont lus depuis la ''Prefetch input queue'', et sont alors décodés. Le décodeur détermine alors la taille de l'instruction. Si l'instruction fait exactement deux octets, elle est exécutée directement. Sinon, les octets manquants sont lus depuis la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Le ''loader'' ne faisait pas que fournir les deux signaux au décodeur d'instruction. Il recevait aussi des signaux de la part du décodeur d'instruction, afin de gérer les lectures dans la ''Prefetch input queue''. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction : elle était alors dépilée de la ''Prefetch input queue''. Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'' un cycle en avance, un cucle avant que l'instruction en cours ne termine. Le résultat est que le ''loader'' réagissait en préchargeant l'instruction suivante. 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 ''Prefetch input queue''.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un registre dédié. Et pour ne pas faire doublon, ce registre remplace le ''program counter''. Après tout, le registre n'est qu'un ''program counter'' ayant pris une avance de quelques cycles d'horloge, qui a été incrémenté en avance. Et cela ne pose pas de problèmes, sauf dans le cas des branchements.
Le décodeur d'instruction dispose d'autres micro-opérations qui agissent sur la ''Prefetch input queue'', trois au total. La première stoppe le préchargement des instructions dans la ''Prefetch input queue''. La seconde vide la ''Prefetch input queue'', elle invalide les instructions préchargées. La troisième permet de reconstituer le ''program counter''. Elles servent à gérer l'exécution des branchements, comme on le verra plus bas.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Cette détection ne peut se faire qu'une fois le branchement décodé : on ne sait pas si l'instruction est un branchement ou autre chose tant que le décodage n'a pas eu lieu, par définition. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
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é.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
tgvje186cmr1rejul6t9jpuqpyw7vh6
744077
744076
2025-06-03T19:26:48Z
Mewtow
31375
/* La prefetch input queue */
744077
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la 'prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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 ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable. Les instructions de ces processeurs ont une taille qui varie entre deux octets et 6 octets. La ''prefetch input queue'' était optimisée pour lire des instructions de deux octets, avec quelques mécanismes pour gérer les instructions plus longues. Il faut dire que les instructions les plus courantes faisaient deux octets, respectivement un octet d'opcode et un octet Mod/RM pour préciser le mode d'adressage.
Si les instructions faisaient deux octets ou plus, La mémoire RAM ne permettait que de charger un octet à la fois. Le décodeur devait attendre que les deux octets d'une instruction soient disponibles dans la ''Prefetch input queue''. Pour cela, un circuit appelé le ''loader'' fournissait deux signaux au décodeur d'instruction : l'un indiquant que le premier octet est déjà chargé, l'autre indiquant que le second octet est disponible.
Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction, afin de gérer les lectures dans la ''Prefetch input queue''. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction : elle était alors dépilée de la ''Prefetch input queue''. Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'' un cycle en avance, un cucle avant que l'instruction en cours ne termine. Le résultat est que le ''loader'' réagissait en préchargeant l'instruction suivante. 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 ''Prefetch input queue''.
Pour gérer les instructions de plus de deux octets, le décodeur pouvait lire une instruction en plusieurs fois dans la ''Prefetch input queue''. Les deux premiers octets sont lus depuis la ''Prefetch input queue'', et sont alors décodés. Le décodeur détermine alors la taille de l'instruction. Si l'instruction fait exactement deux octets, elle est exécutée directement. Sinon, les octets manquants sont lus depuis la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un registre dédié. Et pour ne pas faire doublon, ce registre remplace le ''program counter''. Après tout, le registre n'est qu'un ''program counter'' ayant pris une avance de quelques cycles d'horloge, qui a été incrémenté en avance. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''program counter''.
Et cela ne pose pas de problèmes, sauf dans le cas des branchements. Par exemple, certains branchements relatifs demandent de connaitre la véritable valeur du ''program counter'', pas celle calculée en avance. D'autres situations demandent de connaitre exactement la valeur 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
saagvh0xivfhorbpv85z2alml5rn3mb
744078
744077
2025-06-03T19:32:43Z
Mewtow
31375
/* La prefetch input queue */
744078
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la 'prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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 ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable. Les instructions de ces processeurs ont une taille qui varie entre deux octets et 6 octets. La ''prefetch input queue'' était optimisée pour lire des instructions de deux octets, avec quelques mécanismes pour gérer les instructions plus longues. Il faut dire que les instructions les plus courantes faisaient deux octets, respectivement un octet d'opcode et un octet Mod/RM pour préciser le mode d'adressage.
Si les instructions faisaient deux octets ou plus, La mémoire RAM ne permettait que de charger un octet à la fois. Le décodeur devait attendre que les deux octets d'une instruction soient disponibles dans la ''Prefetch input queue''. Pour cela, un circuit appelé le ''loader'' fournissait deux signaux au décodeur d'instruction : l'un indiquant que le premier octet est déjà chargé, l'autre indiquant que le second octet est disponible.
Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction, afin de gérer les lectures dans la ''Prefetch input queue''. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction : elle était alors dépilée de la ''Prefetch input queue''. Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'' un cycle en avance, un cucle avant que l'instruction en cours ne termine. Le résultat est que le ''loader'' réagissait en préchargeant l'instruction suivante. 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 ''Prefetch input queue''.
Pour gérer les instructions de plus de deux octets, le décodeur pouvait lire une instruction en plusieurs fois dans la ''Prefetch input queue''. Les deux premiers octets sont lus depuis la ''Prefetch input queue'', et sont alors décodés. Le décodeur détermine alors la taille de l'instruction. Si l'instruction fait exactement deux octets, elle est exécutée directement. Sinon, les octets manquants sont lus depuis la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
snop5my30q4kn3h68r5nbtdge1eynd5
744079
744078
2025-06-03T19:33:33Z
Mewtow
31375
/* La prefetch input queue */
744079
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la 'prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable. Les instructions de ces processeurs ont une taille qui varie entre deux octets et 6 octets. La ''prefetch input queue'' était optimisée pour lire des instructions de deux octets, avec quelques mécanismes pour gérer les instructions plus longues. Il faut dire que les instructions les plus courantes faisaient deux octets, respectivement un octet d'opcode et un octet Mod/RM pour préciser le mode d'adressage.
Si les instructions faisaient deux octets ou plus, La mémoire RAM ne permettait que de charger un octet à la fois. Le décodeur devait attendre que les deux octets d'une instruction soient disponibles dans la ''Prefetch input queue''. Pour cela, un circuit appelé le ''loader'' fournissait deux signaux au décodeur d'instruction : l'un indiquant que le premier octet est déjà chargé, l'autre indiquant que le second octet est disponible.
Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction, afin de gérer les lectures dans la ''Prefetch input queue''. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction : elle était alors dépilée de la ''Prefetch input queue''. Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'' un cycle en avance, un cucle avant que l'instruction en cours ne termine. Le résultat est que le ''loader'' réagissait en préchargeant l'instruction suivante. 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 ''Prefetch input queue''.
Pour gérer les instructions de plus de deux octets, le décodeur pouvait lire une instruction en plusieurs fois dans la ''Prefetch input queue''. Les deux premiers octets sont lus depuis la ''Prefetch input queue'', et sont alors décodés. Le décodeur détermine alors la taille de l'instruction. Si l'instruction fait exactement deux octets, elle est exécutée directement. Sinon, les octets manquants sont lus depuis la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
e31iqpu86acm6q7l03adnq2aohvuayd
744080
744079
2025-06-03T19:35:42Z
Mewtow
31375
/* La prefetch input queue */
744080
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la 'prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable. Les instructions de ces processeurs ont une taille qui varie entre deux octets et 6 octets. La ''prefetch input queue'' était optimisée pour lire des instructions de deux octets, avec quelques mécanismes pour gérer les instructions plus longues. Il faut dire que les instructions les plus courantes faisaient deux octets, respectivement un octet d'opcode et un octet Mod/RM pour préciser le mode d'adressage.
Le bus de données du 8086 faisait 16 bits, ce qui fait qu'il était possible de charger une instruction courte en un cycle. Par contre, sur le 8088, un processeur 8 bits, le bus de donnée ne permettait que de lire un octet à la fois. Dans les deux cas, le décodeur devait attendre que les deux octets d'une instruction soient disponibles dans la ''Prefetch input queue''. Pour cela, un circuit appelé le ''loader'' fournissait deux signaux au décodeur d'instruction : l'un indiquant que le premier octet est déjà chargé, l'autre indiquant que le second octet est disponible.
Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction, afin de gérer les lectures dans la ''Prefetch input queue''. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction : elle était alors dépilée de la ''Prefetch input queue''. Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'' un cycle en avance, un cucle avant que l'instruction en cours ne termine. Le résultat est que le ''loader'' réagissait en préchargeant l'instruction suivante. 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 ''Prefetch input queue''.
Pour gérer les instructions de plus de deux octets, le décodeur pouvait lire une instruction en plusieurs fois dans la ''Prefetch input queue''. Les deux premiers octets sont lus depuis la ''Prefetch input queue'', et sont alors décodés. Le décodeur détermine alors la taille de l'instruction. Si l'instruction fait exactement deux octets, elle est exécutée directement. Sinon, les octets manquants sont lus depuis la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
8p8poj6b7qnolqapwfx4nijpg8iirw0
744082
744080
2025-06-03T19:42:16Z
Mewtow
31375
/* La prefetch input queue */
744082
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la 'prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable. Les instructions de ces processeurs ont une taille qui varie entre deux octets et 6 octets. La ''prefetch input queue'' était optimisée pour lire des instructions de deux octets, avec quelques mécanismes pour gérer les instructions plus longues. Il faut dire que les instructions les plus courantes faisaient deux octets, respectivement un octet d'opcode et un octet Mod/RM pour préciser le mode d'adressage.
Le bus de données du 8086 faisait 16 bits, ce qui fait qu'il était possible de charger une instruction courte en un cycle. Par contre, sur le 8088, un processeur 8 bits, le bus de donnée ne permettait que de lire un octet à la fois. Dans les deux cas, le décodeur devait attendre que les deux octets d'une instruction soient disponibles dans la ''Prefetch input queue''. Pour cela, un circuit appelé le ''loader'' fournissait deux signaux au décodeur d'instruction : l'un indiquant que le premier octet est déjà chargé, l'autre indiquant que le second octet est disponible.
Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction, afin de gérer les lectures dans la ''Prefetch input queue''. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction : elle était alors dépilée de la ''Prefetch input queue''. Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'' un cycle en avance, un cucle avant que l'instruction en cours ne termine. Le résultat est que le ''loader'' réagissait en préchargeant l'instruction suivante. 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 ''Prefetch input queue''.
Pour gérer les instructions de plus de deux octets, le décodeur pouvait lire une instruction en plusieurs fois dans la ''Prefetch input queue''. Les deux premiers octets sont lus depuis la ''Prefetch input queue'', et sont alors décodés. Le décodeur détermine alors la taille de l'instruction. Si l'instruction fait exactement deux octets, elle est exécutée directement. Sinon, les octets manquants sont lus depuis la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
qa5rniqtk29ctw095nzv0r4bt1szk60
744083
744082
2025-06-03T19:49:46Z
Mewtow
31375
/* La prefetch input queue */
744083
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable. Les instructions de ces processeurs ont une taille qui varie entre deux octets et 6 octets. La ''prefetch input queue'' était optimisée pour lire des instructions de deux octets, avec quelques mécanismes pour gérer les instructions plus longues. Il faut dire que les instructions les plus courantes faisaient deux octets, respectivement un octet d'opcode et un octet Mod/RM pour préciser le mode d'adressage.
Le bus de données du 8086 faisait 16 bits, ce qui fait qu'il était possible de charger une instruction courte en un cycle. Par contre, sur le 8088, un processeur 8 bits, le bus de donnée ne permettait que de lire un octet à la fois. Dans les deux cas, le décodeur devait attendre que les deux octets d'une instruction soient disponibles dans la ''Prefetch input queue''. Pour cela, un circuit appelé le ''loader'' fournissait deux signaux au décodeur d'instruction : l'un indiquant que le premier octet est déjà chargé, l'autre indiquant que le second octet est disponible.
Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction, afin de gérer les lectures dans la ''Prefetch input queue''. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction : elle était alors dépilée de la ''Prefetch input queue''. Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'' un cycle en avance, un cycle avant que l'instruction en cours ne termine. Le résultat est que le ''loader'' réagissait en préchargeant l'instruction suivante. 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 ''Prefetch input queue''.
Pour gérer les instructions de plus de deux octets, le décodeur pouvait lire une instruction en plusieurs fois dans la ''Prefetch input queue''. Les deux premiers octets sont lus depuis la ''Prefetch input queue'', et sont alors décodés. Le décodeur détermine alors la taille de l'instruction. Si l'instruction fait exactement deux octets, elle est exécutée directement. Sinon, les octets manquants sont lus depuis la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
oelta66ka8fz8uixp5g6y5jba1wibom
744084
744083
2025-06-03T20:03:58Z
Mewtow
31375
/* La prefetch input queue */
744084
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable. Les instructions de ces processeurs ont une taille qui varie entre un octet et 6 octets.
Le bus de données du 8086 faisait 16 bits, ce qui fait qu'il était possible de charger une instruction courte en un cycle. Par contre, sur le 8088, un processeur 8 bits, le bus de donnée ne permettait que de lire un octet à la fois. La ''prefetch input queue'' du 8086 avait un port d'écriture 16 bits relié à la RAM et un port de lecture de 8 bits relié au décodeur. La ''prefetch input queue'' avait des ports de 8 bits uniquement.
Le décodage des instructions se faisait un octet à la fois : le décodeur lisait un octet, puis éventuellement le suivant, et ainsi de suite. Pour gérer les instructions de plus deux octets ou plus, le décodeur pouvait lire une instruction en plusieurs fois dans la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Un circuit appelé le ''loader'' synchronisait le décodeur d'instruction et la ''Prefetch input queue''. Il fournissait un bit qui indiquait si le premier octet d'une instruction était disponible dans la ''Prefetch input queue''. Un second bit indiquait si l'octet suivant était disponible, nous verrons pourquoi dans ce qui suit. Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction, afin de gérer la micro-opération de lecture dans la ''Prefetch input queue''. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction, qui était alors dépilée de la ''Prefetch input queue''. 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 ''Prefetch input queue''.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la ''Prefetch input queue'', pour le 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 envoyait un signal ''Run Next Instruction'' pour commencer le décodage de l'instruction suivante. Sinon, il lit un second octet depuis la ''Prefetch input queue'', le copie dans le registre d'instruction, et relance 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.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
m6qgnwudqrjf61xcazxq328jlgrwogz
744085
744084
2025-06-03T20:09:03Z
Mewtow
31375
/* La prefetch input queue */
744085
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable. Les instructions de ces processeurs ont une taille qui varie entre un octet et 6 octets.
Le décodage des instructions se faisait un octet à la fois : le décodeur lisait un octet, puis éventuellement le suivant, et ainsi de suite. Pour gérer les instructions de plus deux octets ou plus, le décodeur pouvait lire une instruction en plusieurs fois dans la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Un circuit appelé le ''loader'' synchronisait le décodeur d'instruction et la ''Prefetch input queue''. Il fournissait un bit qui indiquait si le premier octet d'une instruction était disponible dans la ''Prefetch input queue''. Un second bit indiquait si l'octet suivant était disponible, nous verrons pourquoi dans ce qui suit. Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction, afin de gérer la micro-opération de lecture dans la ''Prefetch input queue''. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction, qui était alors dépilée de la ''Prefetch input queue''. 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 ''Prefetch input queue''.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la ''Prefetch input queue'', pour le 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 envoyait un signal ''Run Next Instruction'' pour commencer le décodage de l'instruction suivante. Sinon, il lit un second octet depuis la ''Prefetch input queue'', le copie dans le registre d'instruction, et relance 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.
Le bus de données du 8086 faisait 16 bits, alors que celui du 8088 ne permettait que de lire un octet à la fois. Et cela avait un impact sur la ''prefetch input queue''. La ''prefetch input queue'' é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, lui utilisait des registres de 16 bits et un port d'écriture de 16 bits. Sa ''prefetch input queue'' préchargeait les instructions par paquets de 2 octets, non octet par octet, alors que le décodeur consommait les instructions octet par octet. Il s’agissait donc d'une sorte d'intermédiaire entre ''prefetch input queue'' à la 8088 et tampon de préchargement.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
3n8r252hcdo8mk1fxwskn1wndyvkyr1
744086
744085
2025-06-03T20:11:35Z
Mewtow
31375
/* La prefetch input queue */
744086
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable, avec une taille qui varie entre un octet et 6 octets. Cependant, le décodage des instructions se fait 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. En clair, le décodeur lits les instructions longues octet par octet dans la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la ''Prefetch input queue'', pour le 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 ''Prefetch input queue'', le copie dans le registre d'instruction, et relance 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 ''Prefetch input queue''. Il fournissait un bit qui indiquait si le premier octet d'une instruction était disponible dans la ''Prefetch input queue''. Un second bit indiquait si l'octet suivant était disponible, nous verrons pourquoi dans ce qui suit. Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction, qui était alors dépilée de la ''Prefetch input queue''. 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 ''Prefetch input queue''.
Le bus de données du 8086 faisait 16 bits, alors que celui du 8088 ne permettait que de lire un octet à la fois. Et cela avait un impact sur la ''prefetch input queue''. La ''prefetch input queue'' é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, lui utilisait des registres de 16 bits et un port d'écriture de 16 bits. Sa ''prefetch input queue'' préchargeait les instructions par paquets de 2 octets, non octet par octet, alors que le décodeur consommait les instructions octet par octet. Il s’agissait donc d'une sorte d'intermédiaire entre ''prefetch input queue'' à la 8088 et tampon de préchargement.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
k8w6zf2i1y3g41vvft5knewnvnucdkc
744087
744086
2025-06-03T20:12:31Z
Mewtow
31375
/* La prefetch input queue */
744087
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
L'idée est que pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable, avec une taille qui varie entre un octet et 6 octets. Cependant, le décodage des instructions se fait 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. En clair, le décodeur lits les instructions longues octet par octet dans la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la ''Prefetch input queue'', pour le 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 ''Prefetch input queue'', le copie dans le registre d'instruction, et relance 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 ''Prefetch input queue''. Il fournissait un bit qui indiquait si le premier octet d'une instruction était disponible dans la ''Prefetch input queue''. Un second bit indiquait si l'octet suivant était disponible, nous verrons pourquoi dans ce qui suit. Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction, qui était alors dépilée de la ''Prefetch input queue''. 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 ''Prefetch input queue''.
Le bus de données du 8086 faisait 16 bits, alors que celui du 8088 ne permettait que de lire un octet à la fois. Et cela avait un impact sur la ''prefetch input queue''. La ''prefetch input queue'' é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, lui utilisait des registres de 16 bits et un port d'écriture de 16 bits. le port de lecture restait de 8 bits, et était précédé d'un multiplexeur qui prenait 16 bits et sélectionnait l'octet adéquat. Sa ''prefetch input queue'' préchargeait les instructions par paquets de 2 octets, non octet par octet, alors que le décodeur consommait les instructions octet par octet. Il s’agissait donc d'une sorte d'intermédiaire entre ''prefetch input queue'' à la 8088 et tampon de préchargement.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
btix6ekccls2l2glnwwiv883dacktsf
744088
744087
2025-06-03T20:16:04Z
Mewtow
31375
/* La prefetch input queue */
744088
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un ou deux octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
Pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec un tampond e préchargement, le processeur chargeait plusieurs instruction en une seule fois
, puis les exécutait les unes après les autres. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable, avec une taille qui varie entre un octet et 6 octets. Cependant, le décodage des instructions se fait 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. En clair, le décodeur lits les instructions longues octet par octet dans la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la ''Prefetch input queue'', pour le 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 ''Prefetch input queue'', le copie dans le registre d'instruction, et relance 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 ''Prefetch input queue''. Il fournissait un bit qui indiquait si le premier octet d'une instruction était disponible dans la ''Prefetch input queue''. Un second bit indiquait si l'octet suivant était disponible, nous verrons pourquoi dans ce qui suit. Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction, qui était alors dépilée de la ''Prefetch input queue''. 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 ''Prefetch input queue''.
Le bus de données du 8086 faisait 16 bits, alors que celui du 8088 ne permettait que de lire un octet à la fois. Et cela avait un impact sur la ''prefetch input queue''. La ''prefetch input queue'' é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, lui utilisait des registres de 16 bits et un port d'écriture de 16 bits. le port de lecture restait de 8 bits, et était précédé d'un multiplexeur qui prenait 16 bits et sélectionnait l'octet adéquat. Sa ''prefetch input queue'' préchargeait les instructions par paquets de 2 octets, non octet par octet, alors que le décodeur consommait les instructions octet par octet. Il s’agissait donc d'une sorte d'intermédiaire entre ''prefetch input queue'' à la 8088 et tampon de préchargement.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
7opblxsu3i2l2l09pdrb037b4uiisvq
744089
744088
2025-06-03T20:16:39Z
Mewtow
31375
/* La prefetch input queue */
744089
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un ou deux octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
Pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec un tampon de préchargement, le processeur chargeait plusieurs instruction en une seule fois, puis les exécutait les unes après les autres. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable, avec une taille qui varie entre un octet et 6 octets. Cependant, le décodage des instructions se fait 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. En clair, le décodeur lits les instructions longues octet par octet dans la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la ''Prefetch input queue'', pour le 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 ''Prefetch input queue'', le copie dans le registre d'instruction, et relance 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 ''Prefetch input queue''. Il fournissait un bit qui indiquait si le premier octet d'une instruction était disponible dans la ''Prefetch input queue''. Un second bit indiquait si l'octet suivant était disponible, nous verrons pourquoi dans ce qui suit. Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction, qui était alors dépilée de la ''Prefetch input queue''. 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 ''Prefetch input queue''.
Le bus de données du 8086 faisait 16 bits, alors que celui du 8088 ne permettait que de lire un octet à la fois. Et cela avait un impact sur la ''prefetch input queue''. La ''prefetch input queue'' é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, lui utilisait des registres de 16 bits et un port d'écriture de 16 bits. le port de lecture restait de 8 bits, et était précédé d'un multiplexeur qui prenait 16 bits et sélectionnait l'octet adéquat. Sa ''prefetch input queue'' préchargeait les instructions par paquets de 2 octets, non octet par octet, alors que le décodeur consommait les instructions octet par octet. Il s’agissait donc d'une sorte d'intermédiaire entre ''prefetch input queue'' à la 8088 et tampon de préchargement.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
hj8zq9a97hto7uddt4ao3kyn9of9e6j
744090
744089
2025-06-03T20:21:32Z
Mewtow
31375
/* La prefetch input queue */
744090
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un ou deux octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
Pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec un tampon de préchargement, le processeur chargeait plusieurs instruction en une seule fois, puis les exécutait les unes après les autres. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable, avec une taille qui varie entre un octet et 6 octets. Cependant, le décodage des instructions se fait 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. En clair, le décodeur lits les instructions longues octet par octet dans la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''Prefetch input queue''.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la ''Prefetch input queue'', pour le 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 ''Prefetch input queue'', le copie dans le registre d'instruction, et relance 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.
Les octets lus pouvaient être accumulés dans le registre d'instruction, mais aussi ailleurs. Par exemple, prenons le cas d'une addition entre le registre AX et une constante immédiate. L'instruction fait trois octets : un opcode suivie par une constante immédiate codée sur deux octets. 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. Tout se passait du décodage : l'instruction était composée de quatre micro-opérations : une qui lisait le premier octet de la constante immédiate, une seconde pour le second, une troisième micro-opération qui commande l'ALU et fait le calcul.
Un circuit appelé le ''loader'' synchronisait le décodeur d'instruction et la ''Prefetch input queue''. Il fournissait un bit qui indiquait si le premier octet d'une instruction était disponible dans la ''Prefetch input queue''. Un second bit indiquait si l'octet suivant était disponible, nous verrons pourquoi dans ce qui suit. Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction, qui était alors dépilée de la ''Prefetch input queue''. 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 ''Prefetch input queue''.
Le bus de données du 8086 faisait 16 bits, alors que celui du 8088 ne permettait que de lire un octet à la fois. Et cela avait un impact sur la ''prefetch input queue''. La ''prefetch input queue'' é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, lui utilisait des registres de 16 bits et un port d'écriture de 16 bits. le port de lecture restait de 8 bits, et était précédé d'un multiplexeur qui prenait 16 bits et sélectionnait l'octet adéquat. Sa ''prefetch input queue'' préchargeait les instructions par paquets de 2 octets, non octet par octet, alors que le décodeur consommait les instructions octet par octet. Il s’agissait donc d'une sorte d'intermédiaire entre ''prefetch input queue'' à la 8088 et tampon de préchargement.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
7d9gi09tk0ue7jiwylj88abuw16ke5k
744091
744090
2025-06-03T20:27:49Z
Mewtow
31375
/* La prefetch input queue */
744091
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un ou deux octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
Pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec un tampon de préchargement, le processeur chargeait plusieurs instruction en une seule fois, puis les exécutait les unes après les autres. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable, avec une taille qui varie entre un octet et 6 octets. Cependant, le décodage des instructions se fait 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. En clair, le décodeur lit les instructions longues octet par octet dans la ''Prefetch input queue''. Pour cela, le décodeur dispose d'une micro-opération pour lire un octet depuis la ''prefetch input queue''.
Les instructions varient entre 1 et 6 octets, mais tous ne sont pas utile au décodeur d'instruction. Par exemple, le décodeur d'instruction n'a pas besoin d'analyser les constantes immédiates intégrées dans une instruction, ni les adresses mémoires en adressage absolu. Il a besoin de deux octets : l'opcode, et l'octet Mod/RM qui précise le mode d'adressage. Le second est facultatif. En clair, le décodeur a besoin de lire deux octets maximum depuis la ''prefetch input queue'', avant de passer à l’instruction suivante.
Les octets n'étant ni un opcode ni l'octet Mod/RN étaient envoyés ailleurs. Par exemple, prenons le cas d'une addition entre le registre AX et une constante immédiate. L'instruction fait trois octets : un opcode suivie par une constante immédiate codée sur deux octets. 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. Tout se passait du décodage : l'instruction était composée de quatre micro-opérations : une qui lisait le premier octet de la constante immédiate, une seconde pour le second, une troisième micro-opération qui commande l'ALU et fait le calcul. 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. En clair, la ''prefetch input queue'' était connectée au bus interne du processeur !
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la ''Prefetch input queue'', pour le 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 ''Prefetch input queue'' et relance 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 ''Prefetch input queue''. Il fournissait un bit qui indiquait si le premier octet d'une instruction était disponible dans la ''Prefetch input queue''. Un second bit indiquait si l'octet suivant était disponible, nous verrons pourquoi dans ce qui suit. Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction, qui était alors dépilée de la ''Prefetch input queue''. 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 ''Prefetch input queue''.
Le bus de données du 8086 faisait 16 bits, alors que celui du 8088 ne permettait que de lire un octet à la fois. Et cela avait un impact sur la ''prefetch input queue''. La ''prefetch input queue'' é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, lui utilisait des registres de 16 bits et un port d'écriture de 16 bits. le port de lecture restait de 8 bits, et était précédé d'un multiplexeur qui prenait 16 bits et sélectionnait l'octet adéquat. Sa ''prefetch input queue'' préchargeait les instructions par paquets de 2 octets, non octet par octet, alors que le décodeur consommait les instructions octet par octet. Il s’agissait donc d'une sorte d'intermédiaire entre ''prefetch input queue'' à la 8088 et tampon de préchargement.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
5vet1d38ownpjhyh29uqsl5k4ilsu2x
744092
744091
2025-06-03T20:30:05Z
Mewtow
31375
/* La prefetch input queue */
744092
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un ou deux octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
Pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec un tampon de préchargement, le processeur chargeait plusieurs instruction en une seule fois, puis les exécutait les unes après les autres. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable, avec une taille qui varie entre un octet et 6 octets. Cependant, le décodage des instructions se fait 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. En clair, le décodeur lit les instructions longues octet par octet dans la ''Prefetch input queue''.
Les instructions varient entre 1 et 6 octets, mais tous ne sont pas utile au décodeur d'instruction. Par exemple, le décodeur d'instruction n'a pas besoin d'analyser les constantes immédiates intégrées dans une instruction, ni les adresses mémoires en adressage absolu. Il n'a besoin que de deux octets : l'opcode et l'octet Mod/RM qui précise le mode d'adressage. Le second est facultatif. En clair, le décodeur a besoin de lire deux octets maximum depuis la ''prefetch input queue'', 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'instruction fait trois octets : un opcode suivie par une constante immédiate codée sur deux octets. 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 ''prefetch input queue'' était connectée au bus interne du processeur ! Le décodeur dispose d'une micro-opération pour lire un octet depuis la ''prefetch input queue'' et le copier ailleurs dans le chemin de données. Par exemple, l'instruction d'addition entre le registre AX et une constante immédiate était composée de quatre micro-opérations : une qui lisait le premier octet de la constante immédiate, une seconde pour le second, une troisième micro-opération qui commande l'ALU et fait le calcul.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la ''Prefetch input queue'', pour le 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 ''Prefetch input queue'' et relance 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 ''Prefetch input queue''. Il fournissait un bit qui indiquait si le premier octet d'une instruction était disponible dans la ''Prefetch input queue''. Un second bit indiquait si l'octet suivant était disponible, nous verrons pourquoi dans ce qui suit. Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction. Le décodeur envoyait le signal ''Run Next Instruction'' pour lire une instruction, qui était alors dépilée de la ''Prefetch input queue''. 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 ''Prefetch input queue''.
Le bus de données du 8086 faisait 16 bits, alors que celui du 8088 ne permettait que de lire un octet à la fois. Et cela avait un impact sur la ''prefetch input queue''. La ''prefetch input queue'' é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, lui utilisait des registres de 16 bits et un port d'écriture de 16 bits. le port de lecture restait de 8 bits, et était précédé d'un multiplexeur qui prenait 16 bits et sélectionnait l'octet adéquat. Sa ''prefetch input queue'' préchargeait les instructions par paquets de 2 octets, non octet par octet, alors que le décodeur consommait les instructions octet par octet. Il s’agissait donc d'une sorte d'intermédiaire entre ''prefetch input queue'' à la 8088 et tampon de préchargement.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
b5ghgr3billegb8wdkidggqcf2lheib
744093
744092
2025-06-03T20:32:21Z
Mewtow
31375
/* La prefetch input queue */
744093
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un ou deux octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
Pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec un tampon de préchargement, le processeur chargeait plusieurs instruction en une seule fois, puis les exécutait les unes après les autres. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable, avec une taille qui varie entre un octet et 6 octets. Cependant, le décodage des instructions se fait 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. En clair, le décodeur lit les instructions longues octet par octet dans la ''Prefetch input queue''.
Les instructions varient entre 1 et 6 octets, mais tous ne sont pas utile au décodeur d'instruction. Par exemple, le décodeur d'instruction n'a pas besoin d'analyser les constantes immédiates intégrées dans une instruction, ni les adresses mémoires en adressage absolu. Il n'a besoin que de deux octets : l'opcode et l'octet Mod/RM qui précise le mode d'adressage. Le second est facultatif. En clair, le décodeur a besoin de lire deux octets maximum depuis la ''prefetch input queue'', 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'instruction fait trois octets : un opcode suivie par une constante immédiate codée sur deux octets. 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 ''prefetch input queue'' était connectée au bus interne du processeur ! Le décodeur dispose d'une micro-opération pour lire un octet depuis la ''prefetch input queue'' et le copier ailleurs dans le chemin de données. Par exemple, l'instruction d'addition entre le registre AX et une constante immédiate était composée de quatre micro-opérations : une qui lisait le premier octet de la constante immédiate, une seconde pour le second, une troisième micro-opération qui commande l'ALU et fait le calcul.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la ''Prefetch input queue'', pour le 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 ''Prefetch input queue'' et relance 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 ''Prefetch input queue''. Il fournissait deux bits : un premier bit pour indiquer que le premier octet d'une instruction était disponible dans la ''Prefetch input queue'', 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 ''Prefetch input queue''. 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 ''Prefetch input queue''.
Le bus de données du 8086 faisait 16 bits, alors que celui du 8088 ne permettait que de lire un octet à la fois. Et cela avait un impact sur la ''prefetch input queue''. La ''prefetch input queue'' é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, lui utilisait des registres de 16 bits et un port d'écriture de 16 bits. le port de lecture restait de 8 bits, et était précédé d'un multiplexeur qui prenait 16 bits et sélectionnait l'octet adéquat. Sa ''prefetch input queue'' préchargeait les instructions par paquets de 2 octets, non octet par octet, alors que le décodeur consommait les instructions octet par octet. Il s’agissait donc d'une sorte d'intermédiaire entre ''prefetch input queue'' à la 8088 et tampon de préchargement.
Les branchements posent des problèmes avec la ''Prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''Prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
a5hpzf9x4qobw5p2fgzc8b4xnysp3s6
744094
744093
2025-06-03T20:40:41Z
Mewtow
31375
/* La prefetch input queue */
744094
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel assez ancien. Elle est apparue sur le 8086 et le 8088, des processeurs respectivement 8 et 16 bits. Elle été présente sur le 286 et le 386, mais était un peu améliorée. Elle est apparue sur ces processeurs à une époque où le processeur devenait plus rapide que la mémoire.
L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un ou deux octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
Pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec un tampon de préchargement, le processeur chargeait plusieurs instruction en une seule fois, puis les exécutait les unes après les autres. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. Le 386 avait une ''prefetch input queue'' de 16 octets. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable, avec une taille qui varie entre un octet et 6 octets. Cependant, le décodage des instructions se fait 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. En clair, le décodeur lit les instructions longues octet par octet dans la ''Prefetch input queue''.
Les instructions varient entre 1 et 6 octets, mais tous ne sont pas utiles au décodeur d'instruction. Par exemple, le décodeur d'instruction n'a pas besoin d'analyser les constantes immédiates intégrées dans une instruction, ni les adresses mémoires en adressage absolu. Il n'a besoin que de deux octets : l'opcode et l'octet Mod/RM qui précise le mode d'adressage. Le second est facultatif. En clair, le décodeur a besoin de lire deux octets maximum depuis la ''prefetch input queue'', 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'instruction fait trois octets : un opcode suivie par une constante immédiate codée sur deux octets. 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 ''prefetch input queue'' était connectée au bus interne du processeur ! Le décodeur dispose d'une micro-opération pour lire un octet depuis la ''prefetch input queue'' et le copier ailleurs dans le chemin de données. Par exemple, l'instruction d'addition entre le registre AX et une constante immédiate était composée de quatre micro-opérations : une qui lisait le premier octet de la constante immédiate, une seconde pour le second, une troisième micro-opération qui commande l'ALU et fait le calcul.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la ''Prefetch input queue'', pour le 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 ''Prefetch input queue'' et relance 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 ''Prefetch input queue''. Il fournissait deux bits : un premier bit pour indiquer que le premier octet d'une instruction était disponible dans la ''Prefetch input queue'', 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 ''Prefetch input queue''. 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 ''Prefetch input queue''.
Le bus de données du 8086 faisait 16 bits, alors que celui du 8088 ne permettait que de lire un octet à la fois. Et cela avait un impact sur la ''prefetch input queue''. La ''prefetch input queue'' 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, lui utilisait des registres de 16 bits et un port d'écriture de 16 bits. le port de lecture restait de 8 bits, grâce à un multiplexeur sélectionnait l'octet adéquat dans un registre 16 bits. Sa ''prefetch input queue'' préchargeait les instructions par paquets de 2 octets, non octet par octet, alors que le décodeur consommait les instructions octet par octet. Il s’agissait donc d'une sorte d'intermédiaire entre ''prefetch input queue'' à la 8088 et tampon de préchargement.
Le 386 était dans un cas à part. C'était un processeur 32 bits, sa ''prefetch input queue'' contenait 4 registres de 32 bits, un port d'écriture de 32 bits. Mais il ne lisait pas les instructions octet par cotet. A la place, son décodeur d'instruction avait une entrée de 32 bits. Cependant, il gérait des instructions de 8 et 16 bits. Il fallait alors dépiler des instructions de 8, 16 et 32 bits dans la ''prefetch input queue''. 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 branchements posent des problèmes avec la ''prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
lu50g3obx7oh5kyy3scsfhc3vbbasyv
744095
744094
2025-06-03T20:46:32Z
Mewtow
31375
/* La prefetch input queue */
744095
wikitext
text/x-wiki
L'unité de contrôle s'occupe du chargement des instructions et de leur interprétation, leur décodage. Elle contient deux circuits : l''''unité de chargement''' qui charge l'instruction depuis la mémoire, et le '''séquenceur''' qui commande le chemin de données. Les deux circuits sont séparés, mais ils communiquent entre eux, notamment pour gérer les branchements. Dans ce chapitre, nous allons nous intéresser au chargement d'une instruction depuis la RAM/ROM, nous verrons l'étape de décodage de l'instruction au prochain chapitre.
==Le chargement d'une instruction==
[[File:Étape de chargement.png|vignette|upright=1|Étape de chargement.]]
L'étape de chargement (ou ''fetch'') doit faire deux choses : mettre à jour le ''program counter'' et lire l'instruction en mémoire RAM ou en ROM. Les deux étapes peuvent être faites en parallèle, dans des circuits séparés. Pendant que l'instruction est lue depuis la mémoire RAM/ROM, le ''program counter'' est incrémenté et/ou altéré par un branchement.
Pour lire l'instruction, on envoie le ''program counter'' sur le bus d'adresse et on récupère l'instruction sur le bus de données pour l'envoyer sur l'entrée du séquenceur. Et cela demande de connecter le séquenceur au bus mémoire, à travers l'interface mémoire. Et sur ce point, la situation est différente selon que l'on parler d'une architecture Harvard ou Von Neumann.
===Les interconnexions entre séquenceur et bus mémoire===
Sur les architectures Harvard, le séquenceur et le chemin de donnée utilisent des interfaces mémoire séparées. Le séquenceur est directement relié au bus mémoire de la ROM, alors que le chemin de données est connecté à la RAM.
[[File:Microarchitecture de l'interface mémoire d'une architecture Harvard.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture Harvard]]
Mais sur les architectures Von-Neumann et affiliées, le séquenceur et le chemin de donnée partagent la même interface mémoire. Et cela pose deux problèmes.
Le premier problème est que le bus mémoire doit être libéré une fois l'instruction chargée, pour un éventuel accès mémoire. Mais le séquenceur doit conserver une copie de l'instruction chargée, sans quoi il ne peut pas décoder l'instruction correctement. Par exemple, si l'instruction met plusieurs cycles à s'exécuter, le séquenceur doit conserver une copie de l'instruction durant ces plusieurs cycles. La solution à ce problème est un '''registre d'instruction''' situé juste avant le séquenceur, qui mémorise l'instruction chargée.
[[File:Registre d'instruction.png|centre|vignette|upright=2|Registre d'instruction.]]
Le second problème est de gérer le flot des instructions/données entre le bus mémoire, le chemin de données et le séquenceur. Le processeur doit connecter l'interface mémoire soit au séquenceur, soit au chemin de données et cela complique le réseau d'interconnexion interne au processeur.
Une première solution utilise un bus unique qui relie l'interface mémoire, le séquenceur et le chemin de données. Pour charger une instruction, le séquenceur copie le ''program counter'' sur le bus d'adresse, attend que l'instruction lue soit disponible sur le bus de données, puis la copie dans le registre d'instruction. Le bus mémoire est alors libre et peut être utilisé pour lire ou écrire des données, si le besoin s'en fait sentir.
Il faut noter que l'usage d'un bus unique a un impact sur l'organisation interne du chemin de données. Par exemple, le chapitre précédent nous a montré comment implémenter un chemin de données basique, avec des multiplexeurs et démultiplexeurs. Et bien ces chemins de données ne marchent pas vraiment avec un bus interne unique. Il est possible de les adapter, mais au prix d'une complexité largement supérieure. Un bus interne unique marche mieux quand le banc de registre est mono-port. C'est pourquoi il a été utilisé sur d'anciennes architectures appelées des architectures à accumulateur et à pile, que nous verrons dans quelques chapitres, qui utilisaient un banc de registre mono-port.
[[File:Microarchitecture de l'interface mémoire d'une architecture von neumann.png|centre|vignette|upright=2|Microarchitecture de l'interface mémoire d'une architecture von neumann]]
Une autre solution utilise deux bus interne séparés : un connecté au bus d'adresse, l'autre au bus de données. Le ''program counter'' est alors connecté au bus interne d'adresse, le séquenceur est relié au bus interne de données. Notons que la technique marche bien si le ''program counter'' est dans le banc de registre : les interconnexions utilisées pour gérer l'adressage indirect permettent d'envoyer le ''program counter'' sur le bus d'adresse sans ajout de circuit.
Le tout peut être amélioré en remplaçant les deux bus par des multiplexeurs et démultiplexeurs. Le bus d'adresse est précédé par un multiplexeur, qui permet de faire le choix entre ''Program Counter'', adresse venant du chemin de données, ou adresse provenant du séquenceur (adressage absolu). De même, le bus de données est suivi par un démultiplexeur qui envoie la donnée/instruction lue soit au registre d'instruction, soit au chemin de données. Le tout se marie très bien avec les chemins de donnée vu dans le chapitre précédent. Au passage, il faut noter que cette solution nécessite un banc de registre multi-port.
[[File:Connexion du program counter sur les bus avec PC isolé.png|centre|vignette|upright=2|Connexion du program counter sur les bus avec PC isolé]]
===Le chargement des instructions de longueur variable===
Le chargement des instructions de longueur variable pose de nombreux problèmes. Le premier est que mettre à jour le ''program counter'' demande de connaitre la longueur de l'instruction chargée, pour l'ajouter au ''program counter''. Si la longueur d'une instruction est variable, on doit connaitre cette longueur d'une manière où d'une autre. La solution la plus simple indique la longueur de l'instruction dans une partie de l'opcode ou de la représentation en binaire de l'instruction. Mais les processeurs haute performance de nos PC utilisent d'autres solutions, qui n'encodent pas explicitement la taille de l'instruction.
Les processeurs récents utilisent un registre d'instruction de grande taille, capable de mémoriser l'instruction la plus longue du processeur. Par exemple, si un processeur supporte des instructions allant de 1 à 8 octets, comme les CPU x86, le registre d'instruction fera 8 octets. Le décodeur d'instruction vérifie à chaque cycle le contenu du registre d'instruction. Si une instruction est détectée dedans, son décodage commence, peu importe la taille de l'instruction. Une fois l'instruction exécutée, elle est retirée du registre d'instruction. Reste à alimenter le registre d'instruction, à charger des instructions dedans. Et pour cela deux solutions : soit on charge l'instruction en plusieurs fois, soit d'un seul coup.
La solution la plus simple charge les instructions par mots de 8, 16, 32, 64 bits. Ils sont accumulés dans le registre d'instruction jusqu'à détecter une instruction complète. La technique marche nettement mieux si les instructions sont très longues. Par exemple, les CPU x86 ont des instructions qui vont de 1 à 15 octets, ce qui fait qu'ils utilisent cette technique. Précisément, elle marche si les instructions les plus longues sont plus grandes que le bus mémoire.
Une autre solution charge un bloc de mots mémoire aussi grand que la plus grande instruction. Par exemple, si le processeur gère des instructions allant de 1 à 4 octets, il charge 4 octets d'un seul coup dans le registre d'instruction. Il va de soit que cette solution ne marche que si les instructions du processeur sont assez courtes, au plus aussi courtes que le bus mémoire. Ainsi, on est sûr de charger obligatoirement au moins une instruction complète, et peut-être même plusieurs. Le contenu du registre d'instruction est alors découpé en instructions au fil de l'eau.
Utiliser un registre d'instruction large pose problème avec des instructions à cheval entre deux blocs. Il se peut qu'on n'ait pas une instruction complète lorsque l'on arrive à la fin du bloc, mais seulement un morceau. Le problème se manifeste peu importe que l'instruction soit chargée d'un seul coup ou bien en plusieurs fois.
[[File:Instructions non alignées.png|centre|vignette|upright=2|Instructions non alignées.]]
Dans ce cas, on doit décaler le morceau de bloc pour le mettre au bon endroit (au début du registre d'instruction), et charger le prochain bloc juste à côté. On a donc besoin d'un circuit qui décale tous les bits du registre d'instruction, couplé à un circuit qui décide où placer dans le registre d'instruction ce qui a été chargé, avec quelques circuits autour pour configurer les deux circuits précédents.
[[File:Décaleur d’instruction.png|centre|vignette|upright=2|Décaleur d’instruction.]]
Une autre solution se passe de registre d'instruction en utilisant à la place l'état interne du séquenceur. Là encore, elle charge l'instruction morceau par morceau, typiquement par blocs de 8, 16 ou 32 bits. Les morceaux ne sont pas accumulés dans le registre d'instruction, la solution utilise à la place l'état interne du séquenceur. Les mots mémoire de l'instruction sont chargés à chaque cycle, faisant passer le séquenceur d'un état interne à un autre à chaque mot mémoire. Tant qu'il n'a pas reçu tous les mots mémoire de l'instruction, chaque mot mémoire non terminal le fera passer d'un état interne à un autre, chaque état interne encodant les mots mémoire chargés auparavant. S'il tombe sur le dernier mot mémoire d'une instruction, il rentre dans un état de décodage.
==L'incrémentation du ''program counter''==
À chaque chargement, le ''program counter'' est mis à jour afin de pointer sur la prochaine instruction à charger. Sur la quasi-totalité des processeurs, les instructions sont placées dans l'ordre d’exécution dans la RAM. En faisant ainsi, on peut mettre à jour le ''program counter'' en lui ajoutant la longueur de l'instruction courante. Déterminer la longueur de l'instruction est simple quand les instructions ont toutes la même taille, mais certains processeurs ont des instructions de taille variable, ce qui complique le calcul. Dans ce qui va suivre, nous allons supposer que les instructions sont de taille fixe, ce qui fait que le ''program counter'' est toujours incrémenté de la même valeur.
Il existe deux méthodes principales pour incrémenter le ''program counter'' : soit le ''program counter'' a son propre additionneur, soit le ''program counter'' est mis à jour par l'ALU. Pour ce qui est de l'organisation des registres, soit le ''program counter'' est un registre séparé des autres, soit il est regroupé avec d'autres registres dans un banc de registre. En tout, cela donne quatre organisations possibles.
* Un ''program counter'' séparé relié à un incrémenteur séparé.
* Un ''program counter'' séparé des autres registres, incrémenté par l'ALU.
* Un ''program counter'' intégré au banc de registre, relié à un incrémenteur séparé.
* Un ''program counter'' intégré au banc de registre, incrémenté par l'ALU.
[[File:Calcul du program counter.png|centre|vignette|upright=2|les différentes méthodes de calcul du program counter.]]
Les processeurs haute performance modernes utilisent tous la première méthode. Les autres méthodes étaient surtout utilisées sur les processeurs 8 ou 16 bits, elles sont encore utilisées sur quelques microcontrôleurs de faible puissance. Nous auront l'occasion de donner des exemples quand nous parlerons des processeurs 8 bits anciens, dans le chapitre sur les architectures à accumulateur et affiliées.
===Le ''program counter'' mis à jour par l'ALU===
Sur certains processeurs, le calcul de l'adresse de la prochaine instruction est effectué par l'ALU. L'avantage de cette méthode est qu'elle économise un incrémenteur dédié au ''program counter''. Ce qui explique que cette méthode était utilisée sur les processeurs assez anciens, à une époque où un additionneur pouvait facilement prendre 10 à 20% des transistors disponibles. Le désavantage principal est une augmentation de la complexité du séquenceur, qui doit gérer la mise à jour du ''program counter''. De plus, la mise à jour du ''program counter'' ne peut pas se faire en même temps qu'une opération arithmétique, ce qui réduit les performances.
Si on incrémente le ''program counter'' avec l'ALU, il est intéressant de le placer dans le banc de registres. Un désavantage est qu'on perd un registre. Par exemple, avec un banc de registre de 16 registres, on ne peut adresser que 15 registres généraux. De plus ce n'est pas l'idéal pour le décodage des instructions et pour le séquenceur. Si le processeur dispose de plusieurs bancs de registres, le ''program counter'' est généralement placé dans le banc de registre dédié aux adresses. Sinon, il est placé avec les nombres entiers/adresses.
D'anciens processeurs incrémentaient le ''program counter'' avec l'ALU, mais utilisaient bien un registre séparé pour le ''program counter''. Cette méthode est relativement simple à implémenter : il suffit de connecter/déconnecter le ''program counter'' du bus interne suivant les besoins. Le ''program counter'' est déconnecté pendant l’exécution d'une instruction, il est connecté au bus interne pour l'incrémenter. C'est le séquenceur qui gère le tout. L'avantage de cette séparation est qu'elle est plus facile à gérer pour le séquenceur. De plus, on gagne un registre général/entier dans le banc de registre.
===Le compteur programme===
Dans la quasi-totalité des processeurs modernes, le ''program counter'' est un vulgaire compteur comme on en a vu dans les chapitres sur les circuits, ce qui fait qu'il passe automatiquement d'une adresse à la suivante. Dans ce qui suit, nous allons appeler ce registre compteur : le '''compteur ordinal'''. L'usage d'un compteur simplifie fortement la conception du séquenceur. Le séquenceur n'a pas à gérer la mise à jour du ''program counter'', sauf en cas de branchements. De plus, il est possible d'incrémenter le ''program counter'' pendant que l'unité de calcul effectue une opération arithmétique, ce qui améliore grandement les performances. Le seul désavantage est qu'elle demande d'ajouter un incrémenteur dédié au ''program counter''.
Sur les processeurs très anciens, les instructions faisaient toutes un seul mot mémoire et le compteur ordinal était un circuit incrémenteur des plus basique. Sur les processeurs modernes, le compteur est incrémenté par pas de 4 si les instructions sont codées sur 4 octets, 8 si les instructions sont codées sur 8 octets, etc. Concrètement, le compteur est un circuit incrémenteur auquel on aurait retiré quelques bits de poids faible. Par exemple, si les instructions sont codées sur 4 octets, on coupe les 2 derniers bits du compteur. Si les instructions sont codées sur 8 octets, on coupe les 3 derniers bits du compteur. Et ainsi de suite.
Il a existé quelques processeurs pour lesquelles le ''program counter'' était non pas un compteur binaire classique, mais un ''linear feedback shift register''. L'avantage est que le circuit d'incrémentation utilisait bien moins de portes logiques, l'économie était substantielle. Mais un défaut est que des instructions consécutives dans le programme n'étaient pas consécutives en mémoire. Il existait cependant une table de correspondance pour dire : la première instruction est à telle adresse, la seconde à telle autre, etc. Un exemple de processeur de ce type est le TMS 1000 de Texas Instrument, un des premiers microcontrôleur 4 bits datant des années 70.
Une amélioration de la solution précédente utilise un circuit incrémenteur partagé entre le ''program counter'' et d'autres registres. L'incrémenteur est utilisé pour incrémenter le ''program counter'', mais aussi pour effectuer d'autres calculs d'adresse. Par exemple, sur les architectures avec une pile d'adresse de retour, il est possible de partager l'incrémenteur/décrémenteur avec le pointeur de pile ( la technique ne marche pas avec une pile d'appel). Un autre exemple est celui des processeurs qui gèrent automatiquement le rafraichissement mémoire, grâce à, un compteur intégré dans le processeur. Il est possible de partager l'incrémenteur du ''program counter'' avec le compteur de rafraichissement mémoire, qui mémorise la prochaine adresse mémoire à rafraichir. Nous avions déjà abordé ce genre de partage dans le chapitre sur le chemin de données, dans l'annexe sur le pointeur de pile, mais nous verrons d'autres exemples dans le chapitre sur les architectures hybride accumulateur-registre 8/16 bits.
===Quand est mis à jour le ''program counter'' ?===
Le ''program counter'' est mis à jour quand il faut charger une nouvelle instruction. Reste qu'il faut savoir quand le mettre à jour. Incrémenter le ''program counter'' à intervalle régulier, par exemple à chaque cycle d’horloge, fonctionne sur les processeurs où toutes les instructions mettent le même temps pour s’exécuter. Mais de tels processeurs sont très rares. Sur la quasi-totalité des processeurs, les instructions ont une durée d’exécution variable, elles ne prennent pas le même nombre de cycle d'horloge pour s’exécuter. Le temps d’exécution d'une instruction varie selon l'instruction, certaines sont plus longues que d'autres. Tout cela amène un problème : comment incrémenter le ''program counter'' avec des instructions avec des temps d’exécution variables ?
La réponse est que la mise à jour du ''program counter'' démarre quand l'instruction précédente a terminée de s’exécuter, plus précisément un cycle avant. C'est un cycle avant pour que l'instruction chargée soit disponible au prochain cycle pour le décodeur. Pour cela, le séquenceur doit déterminer quand l'instruction est terminée et prévenir le ''program counter'' au bon moment. Il faut donc une interaction entre séquenceur et circuit de chargement. Le circuit de chargement contient une entrée Enable, qui autorise la mise à jour du ''program counter''. Le séquenceur met à 1 cette entrée pour prévenir au ''program counter'' qu'il doit être incrémenté au cycle suivant ou lors du cycle courant. Cela permet de gérer simplement le cas des instructions multicycles.
[[File:Commande de la mise à jour du program counter.png|centre|vignette|upright=2|Commande de la mise à jour du program counter.]]
Toute la difficulté est alors reportée dans le séquenceur, qui doit déterminer la durée d'une instruction. Pour gérer la mise à jour du ''program counter'', le séquenceur doit déterminer la durée d'une instruction. Cela est simple pour les instructions arithmétiques et logiques, qui ont un nombre de cycles fixe, toujours le même. Une fois l'instruction identifiée par le séquenceur, il connait son nombre de cycles et peut programmer un compteur interne au séquenceur, qui compte le nombre de cycles de l'instruction, et fournit le signal Enable au ''program counter''. Mais cette technique ne marche pas vraiment pour les accès mémoire, dont la durée n'est pas connue à l'avance, surtout sur les processeurs avec des mémoires caches. Pour cela, le séquenceur détermine quand l'instruction mémoire est finie et prévient le ''program counter'' quand c'est le cas.
Il existe quelques processeurs pour lesquels le temps de calcul dépend des opérandes de l'instruction. On peut par exemple avoir une division qui prend 10 cycles avec certaines opérandes, mais 40 cycles avec d'autres opérandes. Mais ces processeurs sont rares et cela est surtout valable pour les opérations de multiplication/division, guère plus. Le problème est alors le même qu'avec les accès mémoire et la solution est la même : l'ALU prévient le séquenceur quand le résultat est disponible.
==Les branchements et le ''program counter''==
Un branchement consiste juste à écrire l'adresse de destination dans le ''program counter''. L'implémentation des branchements demande d'extraire l'adresse de destination et d'altérer le ''program counter'' quand un branchement est détecté. Pour cela, le décodeur d'instruction détecte si l'instruction exécutée est un branchement ou non, et il en déduit où trouver l'adresse de destination.
L'adresse de destination se trouve à un endroit qui dépend du mode d'adressage du branchement. Pour rappel, on distingue quatre modes d'adressage pour les branchements : direct, indirect, relatif et implicite. Pour les branchements directs, l'adresse est intégrée dans l'instruction de branchement elle-même et est extraite par le séquenceur. Pour les branchements indirects, l'adresse de destination est dans le banc de registre. Pour les branchements relatifs, il faut ajouter un décalage au ''program counter'', décalage extrait de l'instruction par le séquenceur. Enfin, les instructions de retour de fonction lisent l'adresse de destination depuis la pile. L'implémentation de ces quatre types de branchements est très différente.
L'altération du ''program counter'' dépend de si le ''program counter'' est dans un compteur séparé ou s'il est dans le banc de registre. Nous avons vu plus haut qu'il y a quatre cas différents, mais nous n'allons voir que deux cas : celui où le ''program counter'' est dans le banc de registre et est incrémenté par l'unité de calcul, celui avec un ''program counter'' séparé avec son propre incrémenteur. Les deux autres cas ne sont utilisés que sur des architectures à accumulateur ou à pile spécifiques, que nous n'avons pas encore vu à ce stade du cours et que nous verrons en temps voulu dans le chapitre sur ces architectures.
===L’implémentation des branchements avec un ''program counter'' intégré au banc de registres===
Le cas le plus simple est celui où le ''program counter'' est intégré au banc de registre, avec une incrémentation faite par l'unité de calcul. Charger une instruction revient alors à effectuer une instruction LOAD en adressage indirect, à deux différences près : le registre sélectionné est le ''program counter'', l’instruction est copiée dans le registre d'instruction et non dans le banc de registre. L'incrémentation du ''porgram counter'' est implémenté avec des micro-opérations qui agissent sur le chemin de données, au même titre que les branchements indirects, relatifs et directs.
* Les branchements indirects copient un registre dans le ''program counter'', ce qui revient simplement à faire une opération MOV entre deux registres, dont le ''program counter'' est la destination.
* L'opération inverse d'un branchement indirect, qui copie le ''program counter'' dans un registre général, est utile pour sauvegarder l'adresse de retour d'une fonction.
* Les branchements directs copient une adresse dans le ''program counter'', ce qui les rend équivalents à une opération MOV avec une constante immédiate, dont le ''program counter'' est la destination.
* Les branchements relatifs sont une opération arithmétique entre le ''program counter'' et un opérande en adressage immédiat.
En clair, à partir du moment où le chemin de données supporte les instructions MOV et l'addition, avec les modes d'adressage adéquat, il supporte les branchements. Aucune modification du chemin de données n'est nécessaire, le séquenceur gére le chargement d'une instruction avec les micro-instructions adéquates : une micro-opération ADD pour incrémenter le ''program counter'', une micro-opération LOAD pour le chargement de l'instruction.
Notons que sur certaines architectures, le ''program counter'' est adressable au même titre que les autres registres du banc de registre. Les instructions de branchement sont alors remplacées par des instructions MOV ou des instructions arithmétique équivalentes, comme décrit plus haut.
===L’implémentation des branchements avec un ''program counter'' séparé===
Étudions maintenant le cas où le ''program counter'' est dans un registre/compteur séparé. Rappelons que tout compteur digne de ce nom possède une entrée de réinitialisation, qui remet le compteur à zéro. De plus, on peut préciser à quelle valeur il doit être réinitialisé. Ici, la valeur à laquelle on veut réinitialiser le compteur n'est autre que l'adresse de destination du branchement. Implémenter un branchement est donc simple : l'entrée de réinitialisation est commandée par le circuit de détection des branchements, alors que la valeur de réinitialisation est envoyée sur l'entrée adéquate. En clair, nous partons du principe que le ''program counter'' est implémenté avec ce type de compteur :
[[File:Fonctionnement d'un compteur (décompteur), schématique.jpg|centre|vignette|upright=1.5|Fonctionnement d'un compteur (décompteur), schématique]]
Toute la difficulté est de présenter l'adresse de destination au ''program counter''. Pour les branchements directs, l'adresse de destination est fournie par le séquenceur. L'adresse de destination du branchement sort du séquenceur et est présentée au ''program counter'' sur l'entrée adéquate. Voici donc comment sont implémentés les branchements directs avec un ''program counter'' séparé du banc de registres. Le schéma ci-dessous marche peu importe que le ''program counter'' soit incrémenté par l'ALU ou par un additionneur dédié.
[[File:Unité de détection des branchements dans le décodeur.png|centre|vignette|upright=2|Unité de détection des branchements dans le décodeur]]
Les branchements relatifs sont ceux qui font sauter X instructions plus loin dans le programme. Leur implémentation demande d'ajouter une constante au ''program counter'', la constante étant fournie dans l’instruction. Là encore, deux solutions sont possibles : réutiliser l'ALU pour calculer l'adresse, ou utiliser un additionneur séparé. L'additionneur séparé peut être fusionné avec l'additionneur qui incrémente le ''program counter'' pour passer à l’instruction suivante.
[[File:Unité de chargement qui gère les branchements relatifs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements relatifs.]]
Pour les branchements indirects, il suffit de lire le registre voulu et d'envoyer le tout sur l'entrée adéquate du ''program counter''. Il faut alors rajouter un multiplexeur pour que l'entrée de réinitialisationr ecoive la bonne adresse de destination.
[[File:Unité de chargement qui gère les branchements directs.png|centre|vignette|upright=2|Unité de chargement qui gère les branchements directs et indirects.]]
Toute la difficulté de l'implémentation des branchements est de configurer les multiplexeurs, ce qui est réalisé par le séquenceur en fonction du mode d'adressage du branchement.
==Les optimisations du chargement des instructions==
Charger une instruction est techniquement une forme d'accès mémoire un peu particulier. En clair, charger une instruction prend au minimum un cycle d'horloge, et cela peut rapidement monter à 3-5 cycles, si ce n'est plus. L'exécution des instructions est alors fortement ralentit par la mémoire. Par exemple, imaginez que la mémoire mette 3 cycles d'horloges pour charger une instruction, alors qu'une instruction s’exécute en 1 cycle (si les opérandes sont dans les registres). La perte de performance liée au chargement des instructions est alors substantielle. Heureusement, il est possible de limiter la casse en utilisant des mémoires caches, ainsi que d'autres optimisations, que nous allons voir dans ce qui suit.
===Le tampon de préchargement===
La technique dite du '''préchargement''' est utilisée dans le cas où la mémoire a un temps d'accès important. Mais si la latence de la RAM est un problème, le débit ne l'est pas. Il est possible d'avoir une RAM lente, mais à fort débit. Par exemple, supposons que la mémoire puisse charger 4 instructions (de taille fixe) en 3 cycles. Le processeur peut alors charger 4 instructions en un seul accès mémoire, et les exécuter l'une après l'autre, une par cycle d'horloge. Les temps d'attente sont éliminés : le processeur peut décoder une nouvelle instruction à chaque cycle. Et quand la dernière instruction préchargée est exécutée, la mémoire est de nouveau libre, ce qui masque la latence des accès mémoire.
[[File:Tampon de préchargement d'instruction.png|vignette|Tampon de préchargement d'instruction]]
La seule contrainte est de mettre les instructions préchargées en attente. La solution pour cela est d'utiliser un registre d'instruction très large, capable de mémoriser plusieurs instructions à la fois. Ce registre est de plus connecté à un multiplexeur qui permet de sélectionner l'instruction adéquate dans ce registre. Ce multiplexeur est commandé par le ''program counter'' et quelques circuits annexes. Ce super-registre d'instruction est appelé un '''tampon de préchargement'''.
La méthode a une implémentation nettement plus simple avec des instructions de taille fixe, alignées en mémoire. La commande du multiplexeur de sélection de l'instruction est alors beaucoup plus simple : il suffit d'utiliser les bits de poids fort du ''program counter''. Par exemple, prenons le cas d'un registre d'instruction de 32 octets pour des instructions de 4 octets, soit 8 instructions. Le choix de l'instruction à sélectionner se fait en utilisant les 3 bits de poids faible du ''program counter''.
Mais cette méthode ajoute de nouvelles contraintes d'alignement, similaires à celles vues dans le chapitre sur l'alignement et le boutisme, sauf que l'alignement porte ici sur des blocs d'instructions de même taille que le tampon de préchargement. Si on prend l'exemple d'un tampon de préchargement de 128 bits, les instructions devront être alignées par blocs de 128 bits. C'est à dire qu'idéalement, les fonctions et autres blocs de code isolés doivent commencer à des adresses multiples de 128, pour pouvoir charger un bloc d'instruction en une seule fois. Sans cela, les performances seront sous-optimales.
: Il arrive que le tampon de préchargement ait la même taille qu'une ligne de cache.
Lorsque l'on passe d'un bloc d'instruction à un autre, le tampon de préchargement est mis à jour. Par exemple, si on prend un tampon de préchargement de 4 instructions, on doit changer son contenu toutes les 4 instructions. La seule exception est l'exécution d'un branchement. En effet, lors d'un branchement, la destination du branchement n'est pas dans le tampon de préchargement et elle doit être chargée dedans (sauf si le branchement pointe vers une instruction très proche, ce qui est improbable). Pour cela, le tampon de préchargement est mis à jour précocement quand le processeur détecte un branchement.
Le même problème survient 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 survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans le tampon de préchargement sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas demande de vider le tampon de préchargement si le cas arrive, mais ça 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é.
===La ''prefetch input queue''===
La ''prefetch input queue'' est une sorte de cousine du tampon de préchargement, qui a été utilisée sur les processeurs Intel 8 et 16 bits, ainsi que sur le 286 et le 386. L'idée est là encore de précharger des instructions en avance. Sauf que cette fois-ci, on ne charge pas plusieurs instructions à la fois. Au contraire, les instructions sont préchargées séquentiellement, un ou deux octet à la fois. En conséquence, le tampon de préchargement est remplacé par une mémoire FIFO appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Les instructions sont accumulées une par une dans la PIQ, elles en sortent une par une au fur et à mesure pour alimenter le décodeur d'instruction.
Pendant que le processeur exécute une instruction, on précharge la suivante. Sans ''prefetch input queue'', le processeur chargeait une instruction, la décodait et l'exécutait, puis chargeait la suivante, et ainsi de suite. Avec un tampon de préchargement, le processeur chargeait plusieurs instruction en une seule fois, puis les exécutait les unes après les autres. Avec la ''prefetch input queue'', pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge l'instruction suivante. Si une instruction prend vraiment beaucoup de temps pour s'exécuter, le processeur peut en profiter pour précharger l'instruction encore suivante, et ainsi de suite, jusqu'à une limite de 4/6 instructions (la limite dépend du processeur).
La ''prefetch input queue'' permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. 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 alors mis en pause ou inhibé. Par contre, si la mémoire n'est pas utilisée par l'instruction en cours d'exécution, le processeur lit l'instruction suivante en RAM et la copie dans la ''prefetch input queue''. En conséquence, il arrive que la ''prefetch input queue'' n'ait pas préchargé l'instruction suivante, alors que le décodeur d'instruction est prêt pour la décoder. Dans ce cas, le décodeur doit attendre que l'instruction soit chargée.
Les instructions préchargées dans la ''Prefetch input queue'' y attendent que le processeur les lise et les décode. Les instructions préchargées sont conservées dans leur ordre d'arrivée, afin qu'elles soient exécutées dans le bon ordre, ce qui fait que la ''Prefetch input queue'' est une mémoire de type FIFO. L'étape de décodage pioche l'instruction à décoder dans la ''Prefetch input queue'', cette instruction étant par définition la plus ancienne, puis la retire de la file.
Le premier processeur commercial grand public à utiliser une ''prefetch input queue'' était le 8086. Il pouvait précharger à l'avance 6 octets d’instruction. L'Intel 8088 avait lui aussi une ''Prefetch input queue'', mais qui ne permettait que de précharger 4 instructions. Le 386 avait une ''prefetch input queue'' de 16 octets. 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.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Vous remarquerez que j'ai exprimé la capacité de la ''prefetch input queue'' en octets et non en instructions. La raison est que sur les processeurs x86, les instructions sont de taille variable, avec une taille qui varie entre un octet et 6 octets. Cependant, le décodage des instructions se fait 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. En clair, le décodeur lit les instructions longues octet par octet dans la ''Prefetch input queue''.
Les instructions varient entre 1 et 6 octets, mais tous ne sont pas utiles au décodeur d'instruction. Par exemple, le décodeur d'instruction n'a pas besoin d'analyser les constantes immédiates intégrées dans une instruction, ni les adresses mémoires en adressage absolu. Il n'a besoin que de deux octets : l'opcode et l'octet Mod/RM qui précise le mode d'adressage. Le second est facultatif. En clair, le décodeur a besoin de lire deux octets maximum depuis la ''prefetch input queue'', 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'instruction fait trois octets : un opcode suivie par une constante immédiate codée sur deux octets. 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 ''prefetch input queue'' était connectée au bus interne du processeur ! Le décodeur dispose d'une micro-opération pour lire un octet depuis la ''prefetch input queue'' et le copier ailleurs dans le chemin de données. Par exemple, l'instruction d'addition entre le registre AX et une constante immédiate était composée de quatre micro-opérations : une qui lisait le premier octet de la constante immédiate, une seconde pour le second, une troisième micro-opération qui commande l'ALU et fait le calcul.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la ''Prefetch input queue'', pour le 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 ''Prefetch input queue'' et relance 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 ''Prefetch input queue''. Il fournissait deux bits : un premier bit pour indiquer que le premier octet d'une instruction était disponible dans la ''Prefetch input queue'', 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 ''Prefetch input queue''. 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 ''Prefetch input queue''.
Le bus de données du 8086 faisait 16 bits, alors que celui du 8088 ne permettait que de lire un octet à la fois. Et cela avait un impact sur la ''prefetch input queue''. La ''prefetch input queue'' 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, lui utilisait des registres de 16 bits et un port d'écriture de 16 bits. le port de lecture restait de 8 bits, grâce à un multiplexeur sélectionnait l'octet adéquat dans un registre 16 bits. Sa ''prefetch input queue'' préchargeait les instructions par paquets de 2 octets, non octet par octet, alors que le décodeur consommait les instructions octet par octet. Il s’agissait donc d'une sorte d'intermédiaire entre ''prefetch input queue'' à la 8088 et tampon de préchargement.
Le 386 était dans un cas à part. C'était un processeur 32 bits, sa ''prefetch input queue'' contenait 4 registres de 32 bits, un port d'écriture de 32 bits. Mais il ne lisait pas les instructions octet par cotet. A la place, son décodeur d'instruction avait une entrée de 32 bits. Cependant, il gérait des instructions de 8 et 16 bits. Il fallait alors dépiler des instructions de 8, 16 et 32 bits dans la ''prefetch input queue''. 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 branchements posent des problèmes avec la ''prefetch input queue'' : à cause d'eux, on peut charger à l'avance des instructions qui sont zappées par un branchement et ne sont pas censées être exécutées. Si un branchement est chargé, toutes les instructions préchargées après sont potentiellement invalides. Si le branchement est non-pris, les instructions chargées sont valides, elles sont censées s’exécuter. Mais si le branchement est pris, elles ont été chargées à tort et ne doivent pas s’exécuter.
Pour éviter cela, la ''prefetch input queue'' est vidée quand le processeur détecte un branchement. Pour cela, le décodeur d'instruction dispose d'une micro-opération qui vide la ''Prefetch input queue'', elle invalide les instructions préchargées. Cette détection ne peut se faire qu'une fois le branchement décodé, qu'une fois que l'on sait que l'instruction décodée est un branchement. En clair, le décodeur invalide la ''Prefetch input queue'' quand il détecte un branchement. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la ''Prefetch input queue'' quand une interruption survient, avant de la traiter.
Il faut noter qu'une ''Prefetch input queue'' interagit assez mal avec le ''program counter''. En effet, la ''Prefetch input queue'' précharge les instructions séquentiellement. Pour savoir où elle en est rendue, elle mémorise l'adresse de la prochaine instruction à charger dans un '''registre de préchargement''' dédié. Il serait possible d'utiliser un ''program counter'' en plus du registre de préchargement, mais ce n'est pas la solution qui a été utilisée. Les processeurs Intel anciens ont préféré n'utiliser qu'un seul registre de préchargement en remplacement du ''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.
Et cela ne pose pas de problèmes, sauf avec certains 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.
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é.
Le décodeur d'instruction dispose aussi d'une micro-opération qui stoppe le préchargement des instructions dans la ''Prefetch input queue''.
: Pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, la relation entre ''prefetch input queue'' et pipeline est quelque peu complexe. En apparence, la ''prefetch input queue'' permet une certaines forme de pipeline, en chargeant une instruction pendant que la précédente s'exécute. Les problématiques liées aux branchements ressemblent beaucoup à celles de la prédiction de branchement. Cependant, il est possible de dire qu'il s'agit plus d'une technique de préchargement que de pipeline. La différence entre les deux est assez subtile et les frontières sont assez floues, mais le fait est que l'instruction en cours d'exécution et le préchargement peuvent tout deux faire des accès mémoire et que l'instruction en cours a la priorité. Un vrai pipeline se débrouillerait avec une architecture Harvard, non avec un bus mémoire unique pour le chargement des instruction et la lecture/écriture des données.
===Le ''Zero-overhead looping''===
Nous avions vu dans le chapitre sur les instructions machines, qu'il existe des instructions qui permettent de grandement faciliter l'implémentation des boucles. Il s'agit de l'instruction REPEAT, utilisée sur certains processeurs de traitement de signal. Elle répète l'instruction suivante, voire une série d'instructions, un certain nombre de fois. Le nombre de répétitions est mémorisé dans un registre décrémenté à chaque itération de la boucle. Elle permet donc d'implémenter une boucle FOR facilement, sans recourir à des branchements ou des conditions.
Une technique en lien avec cette instruction permet de grandement améliorer les performances et la consommation d'énergie. L'idée est de mémoriser la boucle, les instructions à répéter, dans une petite mémoire cache située entre l'unité de chargement et le séquenceur. Le cache en question s'appelle un '''tampon de boucle''', ou encore un ''hardware loop buffer''. Grâce à lui, il n'y a pas besoin de charger les instructions à chaque fois qu'on les exécute, on les charge une bonne fois pour toute : c'est plus rapide. Bien sûr, il ne fonctionne que pour de petites boucles, mais il en est de même avec l'instruction REPEAT.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le chemin de données
| prevText=Le chemin de données
| next=L'unité de contrôle
| nextText=L'unité de contrôle
}}
</noinclude>
2j2c0fl62nn1m9v81aqg2ixu2g94m57
Jeu de rôle sur table — Jouer, créer/Créer un jeu de rôle
0
82237
744047
742095
2025-06-03T15:30:26Z
Cdang
1202
testez !
744047
wikitext
text/x-wiki
<noinclude>{{NavTitre|book={{BASEPAGENAME}}|prev=Créer un univers|next=Abréviations}}</noinclude>
Nous avons vu :
* [[../Créer une nouvelle règle|comment créer des règles]] ;
* [[../Créer un univers|comment créer un univers]] ;
il est donc temps d'aborder la question de la création d'un jeu de rôle complet.
== Processus global ==
Donc vous avez un groupe de joueurs et joueuses régulières. Vous avez joué des parties régulières, soit en développant une campagne, soit par des parties indépendantes mais avec une logique récurrente : type de parties, univers… Au fur et à mesure, vous avez développé un univers et créé des règles spécifiques pour avoir des moments de jeux intéressants. Vous avez donc… déjà créé votre jeu de rôle. Vous voulez alors peut-être le partager, le diffuser, voire le vendre.
Ou alors : vous avez une idée de partie type et vous voulez créer un jeu pour concrétiser ce que vous avez en tête. Là, tout est à créer…
== L'hygiène de l'écrivain ==
Pour tenir sur le long terme, vous pouvez prendre des bonnes habitudes d'écriture<ref>{{lien web |url=https://blog.lulu.com/6-writing-habits/ |titre=6 Writing Habits to Write More |auteur=paul |site=Lulu blog |date=2020-09-18 |consulté le2025-04-19 |lang=en}}.</ref> :
# Écrivez chaque jour — chisissez un moment et respectez-le !
# Définissez des objectifs d'écriture — imposez-vous un nombre de mot à écrire chaque jour.
# Dédiez du temps à l'écriture — réservez du temps, que ce soit 10 minutes ou une heure.
# Un lieu, un lieu, un lieu — dédiez un lieu qui soit votre « espace d'écriture ».
# Faite de l'édition plus tard — les fautes d'orthographe n'ont pas d'importance pour un premier jet.
# Créez et surveillez des éléments mesurables — suivez votre progression et ajustez vos buts.
{{...}}
== Testez, testez, testez… ==
{{citation bloc |1=''À propos du ''Butin des Gobelins'' pour le jeu de rôle ''Coureurs d’orage.''
: C'est qui est intéressant (et nouveau en ce qui me concerne) c'est que je me retrouve dans une position « à la @Johan Scipion » (toutes proportions gardées) avec ce micro-scénar.
: Cinquième fois que tu fais jouer la même proposition (règles + scénario), tu commences à repérer des trucs, des axes d'amélioration qui ne sautent pas forcément aux yeux sur seulement deux itérations (quand je playtestais mes modules ''CdO'', je les faisais jouer deux fois).
: Bref tu augmentes encore sensiblement ton niveau d'affinage parce que tu commences a avoir en tête un catalogue de réactions possibles basé sur du concret, du vu-et-joué, pas de la supputation.
:
: C'est une expérience que je recommande aux MJ un peu exigeants et qui veulent bosser leur « métier ».
— Islayre d'Argolh » mar. juin 03, 2025 1:45 pm
: Complètement d'accord. Le replay :
:
:+ Est un super outil de ''game design''.
:+ Bonifie la maîtrise donc l'expérience ludique de toute la table.
:+ Est supra fun.
:
: Ce sont les raisons pour lesquelles je lui ai récemment consacré un numéro de ''Sombre'' : https://www.terresetranges.net/forums/viewtopic.php?pid=21690#p21690
— Johan Scipion » mar. juin 03, 2025 4:09 pm
|3={{lien web |url=https://www.casusno.fr/viewtopic.php?p=2273179#p2273179 |titre=Re: Retours de partie : Il était une fois [Sondage en page 1]/Re: Sombre, la peur comme au cinéma |site=Casus Non Officiel |date=3 juin 2025 |consulté le=2025-06-03}}
}}
== Références ==
{{références}}
----
''[[../Créer un univers/]]'' < [[Jeu de rôle sur table — Jouer, créer|↑]] > ''[[../Abréviations/]]''
{{DEFAULTSORT:Creer un jeu de role}}
[[Catégorie:Jeu de rôle sur table — Jouer, créer (livre)]]
jecfeuk4lzvjpudfostnh4ww1tkaep6
Astrologie/L'interprétation d'un thème astral/Astrologie dynamique/révolution solaire
0
82413
744051
743775
2025-06-03T16:15:39Z
Kad'Astres
30330
+ Cat
744051
wikitext
text/x-wiki
La révolution solaire est un outil astrologique utilisé pour analyser les tendances, événements et dynamiques personnelles d'une année à venir, en se basant sur le retour du Soleil à sa position exacte (en degré, minute, et seconde) qu’il occupait au moment de la naissance.
L’interprétation se fait en combinant plusieurs éléments :
:1. Ascendant de révolution solaire
:Donne le ton général de l’année : votre attitude, comment vous êtes perçu·e, votre manière d’agir.
:2. Planètes dans les maisons
:Montre où se concentreront les énergies.
:Exemple : Mars en Maison 6 = dynamisme au travail, mais attention au stress.
:3. Aspects planétaires dans la révolution
:Révèlent les tensions ou harmonies spécifiques de l’année.
:4. Superposition avec le thème natal
:On compare la révolution solaire avec le thème natal pour voir comment les événements de l’année s’insèrent dans votre chemin de vie global.
:5. Maître de l’Ascendant
:Son placement et ses aspects sont cruciaux : il colore la direction de l’année.
[[Catégorie:Astrologie]]
dezuw3m4qr80f6fn021zj1awttmc5vw
Astrologie/L'interprétation d'un thème astral/Astrologie dynamique/directions secondaires
0
82414
744049
743774
2025-06-03T16:14:24Z
Kad'Astres
30330
+ Cat
744049
wikitext
text/x-wiki
Il existe deux méthodes principales pour calculer les directions secondaires, aussi appelées progressions secondaires : la progression des planètes par rapport aux positions natales et la création d'un nouveau thème progressé.
[[Catégorie:Astrologie]]
56t83q7t3quosat9oixeywah0sx8qbx
Astrologie/L'interprétation d'un thème astral/Astrologie dynamique/transit
0
82415
744050
743776
2025-06-03T16:15:03Z
Kad'Astres
30330
+ Cat
744050
wikitext
text/x-wiki
Un transit se produit lorsque les positions actuelles des planètes (dans le ciel) forment un aspect (comme une conjonction, un carré, une opposition, etc.) avec les positions des planètes, ou des points sensibles, du thème astral de naissance.
[[Catégorie:Astrologie]]
tigs4sruzgtcnuufg3c0oo0o2n8mz4i
Astrologie/Préliminaires astronomiques/La mesure du temps/Calcul du temps sidéral à partir de l'heure légale
0
82416
744048
743944
2025-06-03T16:12:02Z
Kad'Astres
30330
+ Cat
744048
wikitext
text/x-wiki
On utilise l'équation du temps pour convertir le temps des horloges (temps solaire moyen) en temps apparent. Puis on convertit le temps solaire vrai en temps sidéral.
[[Catégorie:Astrologie]]
ngly8bujginorvplr60ppwpcoqmq23q
744067
744048
2025-06-03T18:59:01Z
Kad'Astres
30330
744067
wikitext
text/x-wiki
{{SI|refonte totale}}
On utilise l'équation du temps pour convertir le temps des horloges (temps solaire moyen) en temps apparent. Puis on convertit le temps solaire vrai en temps sidéral.
[[Catégorie:Astrologie]]
gvgx38mixh3ut5thlxzh8xrrm3p3kqp
744071
744067
2025-06-03T19:13:39Z
Kad'Astres
30330
Contenu remplacé par « {{SI|refonte totale}} »
744071
wikitext
text/x-wiki
{{SI|refonte totale}}
ehonxiv34l7r9zetnh7ih45v7soi8vy
Neurosciences/La barrière hémato-encéphalique
0
82423
744031
743941
2025-06-03T13:35:48Z
Mewtow
31375
/* L'entrée du fer dans le cerveau */
744031
wikitext
text/x-wiki
Le cerveau est séparé de la circulation sanguine, par une '''barrière hémato-encéphalique''' qui isole le cerveau du reste du corps. Elle est présente sur tous les vaisseaux sanguins cérébraux, ou presque. Les molécules sanguines ne peuvent pas toutes passer à travers la barrière hémato-encéphalique : si certaines le peuvent, d'autres ne le peuvent pas. On dit que la barrière hémato-encéphalique a une '''perméabilité sélective'''. C'est d'ailleurs la raison d'être de la barrière hémato-encéphalique : empêcher le passage de "molécules nuisibles" à travers les capillaires, pour éviter qu'elles passent dans le cerveau. Les ions (calcium, potassium, sodium, et autres) ne peuvent pas traverser cette barrière, leur concentration étant importante pour le bon fonctionnement des potentiels d'action. D'autres molécules importantes, comme le glucose ou d'autres nutriments, peuvent la traverser.
Beaucoup de médicaments ne peuvent pas passer la barrière hémato-encéphalique, ce qui est parfois un avantage, parfois un défaut. C'est un défaut si la molécule a le potentiel d'agir positivement sur le cerveau, mais que la barrière hémato-encéphalique lui empêche d'rentrer. Par exemple, imaginons qu'on découvre une molécule thérapeutique qui permettrait d'augmenter la neurogenèse (la fabrication de nouveaux neurones). Si elle passait à travers la barrière hémato-encéphalique, elle pourrait soigner des maladies comme Alzheimer. Mais elle ne servirait à rien si elle ne pouvait pas rentrer dans le cerveau !
À l'inverse, ce peut être un avantage dans certains cas. Par exemple, une molécule noradrénergique utilisée pour traiter les troubles du rythme cardiaque ne devrait, idéalement, pas agir sur le cerveau. Si elle ne passe pas à travers la barrière hémato-encéphalique, tout va pour le mieux : la molécule n'agira pas sur le cerveau, ce qui fait des effets secondaires d'évités. Par contre, si ce n'est pas le cas, cela pourrait causer des effets secondaires neurologiques ou psychiatriques assez graves.
: Avant de poursuivre, sachez que nous utiliserons souvent l'abréviation, BHE pour désigner la barrière hémato-encéphalique.
==La barrière hémato-encéphalique : les capillaires cérébraux==
La barrière hémato-encéphalique se situe au niveau des vaisseaux sanguins du système nerveux, à quelques exceptions. Les exceptions en question sont des vaisseaux où la barrière hémato-encéphalique n'existe pas, mais ils sont très rares. Pour être précis, les vaisseaux sanguins protégés par la barrière hémato-encéphalique sont les capillaires, à savoir les petits vaisseaux suffisamment fins pour laisser passer de l'oxygène, du CO2, des nutriments, quelques petites molécules.
Les capillaires ne doivent pas être confondus avec les artères et les veines, des vaisseaux plus gros. La différence principale est que les capillaires sont composés d'une unique couche de cellules jointes, juxtaposées les unes à côté des autres (l'ensemble forme ce qu'on appelle un épithélium). Les artères ajoutent plusieurs couches en plus de l'épithélium, dont une couche de muscles qui aident à contracter ou dilater l'artère, et un second épithélium qui enveloppe l'artère. Même chose pour les veines, avec cependant des couches en plus. Les artères et veines du système nerveux ne sont pas différentes de celles trouvées dans le reste du corps. Par contre, les capillaires cérébraux sont totalement différents sur deux points.
===Des capillaires continus protégés par des astrocytes===
La barrière hémato-encéphalique est formée par deux choses : le vaisseaux capillaire lui-mêmes, et une couche protectrice d'astrocytes tout autour. La première barrière est liée au fait que les capillaires du système nerveux central sont moins étanches que les capillaires normaux. La seconde barrière est formée par les astrocytes, qui entourent les vaisseau sanguins.
[[File:Blood brain barrier.png|centre|vignette|upright=2|Barrière hémato-encéphalique.]]
Les capillaires peuvent se classer en trois types, suivant la manière dont l’épithélium est formé : capillaires sinusoïdaux, fenêtrés et continus. La différence est que les deux sont perclus de trous, mais pas le dernier.
Dans les '''capillaires sinusoïdaux''', les cellules de l'épithélium sont liées les unes aux autres, grâce à des espèces de crochets moléculaires, mais d'une manière assez lâche. Les crochets attachent les cellules à leurs voisines, mais laissent des espaces entre les cellules, qui peut prendre la forme de fentes. Cela permet de laisser des cellules comme des globules blancs et des globule rouges, des grosses protéines, mais ils peuvent aussi malheureusement laisser passer les virus et quelques toxines.
[[File:202104 Sinusoidal capillary.svg|centre|vignette|upright=1|Capillaires sinusoïdal.]]
Les capillaires fenêtrés et continus ont des cellules collées les unes aux autres par des jonctions communicantes, les mêmes que celles qui servent pour les synapses électriques. Elles sont beaucoup plus nombreuses que les crochets moléculaires des capillaires normaux, ce qui colle mieux les cellules du capillaire. En conséquence, les espaces entre cellules n'existent pas, ce qui ne permet pas de laisser passer des cellules, virus ou protéines. Mais par contre, les '''capillaires fenêtrés''' ont des pores qui traversent les cellules du vaisseaux sanguin et permettent de laisser passer de grosses molécules, comme des protéines, mais aussi des virus et autres bactéries de petite taille.
[[File:202104 Fenestrated capillary.svg|centre|vignette|upright=1|Capillaires fenêtré.]]
Les '''capillaires continus''' ne laissent ni espace entre cellules, ni pore, ni quoique ce soit. En conséquence, ils sont vraiment imperméables, au point qu'ils fournissent une première barrière contre les agressions extérieures, notamment contre les virus, les bactéries ou les toxines de grande taille. De tel capillaires ne se trouvent qu'en deux endroits : les muscles squelettiques, et le système nerveux. La première barrière hémato-encéphalique est le fait que tous les capillaires du système nerveux sont des capillaires continus.
[[File:202104 Continuous capillary.svg|centre|vignette|upright=1|Capillaires continu.]]
Ensuite, la seconde couche est formée par les astrocytes qui entourent les vaisseaux. Plus précisément, les capillaires cérébraux ne sont pas entourés de muscles, comme leurs congénères, mais sont recouverts par des astrocytes et des péricytes. Le tout forme une double couche : une première couche formé par le vaisseau sanguin, une seconde couche formée par astrocytes. Une molécule qui souhaite passer cette barrière doit donc travers plus d'obstacles qu'ailleurs. La protection est meilleure comparé à la paroi d'un vaisseau sanguin, les membranes astrocytaires faisant office de renforcement.
[[File:Blood Brain Barrier.jpg|centre|vignette|upright=2.0|Barrière hémato-encéphalique]]
===Les espaces périvasculaires/de Virchow–Robin===
Outre la barrière hémato-encéphalique, certains vaisseaux sanguins cérébraux sont entourés par un manchon rempli de fluide : l''''espace périvasculaire''', aussi appelé ''espace de Virchow–Robin''. Ils sont surtout présents sur les vaisseaux des méninges, dans les couches situées sous l'arachnoïde et la pie-mère. Mais on en trouve aussi autour des vaisseaux des ganglions de la base et de quelques autres vaisseaux. Le cas le plus important est de loin les vaisseaux qui alimentent les organes circumventriculaires, à savoir des aires cérébrales qui ne sont pas protégées par la barrière hémato-encéphalique, sans doute pour assurer leur protection immunologique. On en trouve aussi en dehors du cerveau, dans le foie, le thymus et quelques autres organes, mais ceci est une autre histoire...
Les espaces périvasculaires possèdent des fonctions variées, qui vont de l'immunité à la régulation du transfert d'ions et de solutés. Ils servent, entre autres, de protection immunologique. Preuve en est la présence d'un grand nombre de globules blancs, surtout lors d'infections : lymphocytes T et B, monocytes, etc. Mais leur rôle principal est la régulation des échanges de fluide avec le cerveau, de permettre un échange sang<->cerveau aisé des solutés, ions et autres petites molécules solubles.
==Le passage des molécules à travers la barrière hémato-encéphalique : généralités==
La barrière hémato-encéphalique est donc composée des capillaires sanguins du cerveau, parfois recouvert par une couche d'astrocytes. Les cellules des capillaires sanguins sont appelées des '''cellules endothéliales'''. Les molécules doivent donc traverser une couche de cellules endothéliales, pour ensuite être absorbée par les astrocytes qui s'occupent de métaboliser ce qui doit l'être. La traversée d'une cellule endothéliale demande que la molécule traverse la membrane de cette cellule deux fois : une fois pour le passage sang->cellule endothéliale, une seconde fois pour sortir de la cellule endothéliale et rentrer dans le cerveau. Il y a donc deux passages à travers une membrane cellulaire, et cela nous amène à parler plus en détail des membranes cellulaires.
Il existe divers mécanismes qui permettent à des molécules de traverser les membranes cellulaires des cellules endothéliales. Les mécanismes sont résumés dans le schéma ci-dessous. Nous allons les détailler un par un dans ce qui suit, mais les images devraient vous donner une petite idée de comment fonctionnent ces mécanismes.
[[File:Blood-brain barrier transport en.png|centre|vignette|upright=2.5|Mécanismes de transport à travers la barrière hémato-encéphalique.]]
Les mécanismes en question peuvent se classer en deux types. Les mécanismes de '''transport passifs''' utilisent un gradient de concentration, ce qui permet de faire passer une molécule d'un endroit où elle est très concentrée vers un endroit où elle l'est moins. Ils ne consomment pas d'énergie. A l'inverse, les mécanismes de '''transport actif''' consomment de l'énergie sous forme d'ATP, mais sont capables d'aller contre un gradient de concentration. Pour donner un exemple, les pompes ioniques sont des mécanismes de transport actif, les canaux ioniques sont du transport passif. Il existe une sorte d'équivalent mais pour les molécules plus grosses.
===La diffusion simple et le transport paracellualire===
Les deux mécanismes de transport les plus simples sont le transport paracellulaire et la diffusion simple. Avec ceux deux-là, de très petites molécules peuvent passer la BHE directement, en suivant un gradient de concentration, sans rencontrer d'obstacle digne de ce nom. La BHE est naturellement perméable pour ces petites molécules. Ce sont donc des mécanismes de transport passif.
Le premier est le '''transport paracellulaire''', où les molécules passent entre les cellules endothéliales des capillaires sanguins. Les cellules endothéliales sont certes collées les unes au autres, mais pas parfaitement. Il reste toujours quelques espaces qui peuvent laisser passer de petites molécules. Le terme paracellulaire est assez parlant : para- pour dire à côté, et cellulaire.
Cependant, dans le cerveau, ce mécanisme de transport est rendu impossible. Les capillaires sanguins sont continus, il n'y a pas d'espace entre les cellules endothéliales. Les cellules endothéliales sont collées les unes au autres grâce à des jonctions communicantes et autres jonctions cellulaires, qui ne laissent presque pas d'espace libre. En conséquence, le transport paracellulaire est empêché, ce qui en fait un premier mécanisme de protection, une première barrière hémato-encéphalique. Les molécules du sang doivent donc traverser les cellules endothéliales, pas passer à côté.
Le second mécanisme de transport passif est la '''diffusion simple'''. Avec elle, une molécule passe à travers la barrière hémato-encéphalique sans rencontrer d'obstacles, comme dans du beurre, sans recourir à des canaux ioniques ni quoique ce soit d'équivalent.
[[File:Scheme simple diffusion in cell membrane-fr.svg|centre|vignette|upright=2|Diffusion simple.]]
Pour comprendre quelles molécules peuvent traverser ainsi, faisons un rappel rapide sur les membranes cellulaires, ainsi que sur les molécules hydrophobes et hydrophiles. Les molécules sont souvent classés en molécules hydrophiles (attirées par l'eau) et hydrophobes (repoussées par l'eau). Le caractère hydrophile et hydrophobe est lié à des interactions électriques avec les molécules d'eau. Les molécules hydrophiles sont généralement polaires, avec une charge positive à un côté de la molécule et un côté négatif de l'autre. Par contre, les molécules hydrophobe sont généralement neutres.
Les molécules hydrophiles se dissolvent très bien dans l'eau, alors que les molécules hydrophobes ne se dissolvent pas, et cela n'a rien de surprenant. Par contre, le caractère hydrophobe/hydrophile a aussi un impact sur la dissolution dans des graisses, les lipides. Les molécules hydrophobes sont généralement liposolubles, ce qui veut dire qu'elles se dissolvent dans les graisses, les lipides. A l'inverse, les molécules hydrophiles ne se dissolvent pas dans les graisses.
Et il se trouve que la membrane cellulaire est composée d'une double couche de lipides ! Chaque molécule de lipide a une tête hydrophile (attirée par l'eau), et une queue hydrophobe (qui est repoussée par l'eau). La membrane a donc un cœur hydrophobe et un extérieur hydrophile. Les molécules hydrophiles ne traversent pas le cœur hydrophobe, car elles ne se dissolvent pas dans les lipides. Par contre, les molécules hydrophobes traversent la membrane cellulaire sans trop de soucis. La traversée d'une membrane cellulaire n'est donc pas la même entre une molécule hydrophobe et une molécule hydrophile.
[[File:Fluid Mosaic.svg|centre|vignette|upright=2|Membrane cellaulaire.]]
La diffusion simple n'est donc possible que si la molécule peut se dissoudre dans la membrane cellulaire, donc se dissoudre dans des lipides. En clair, c'est limité aux molécules hydrophobes, liposolubles. De plus, les molécules doivent être assez petites, les grosses molécules ne peuvent pas traverser facilement. Une molécule plus grosse que la membrane aura du mal à se dissoudre dedans.
Pour résumer : diffusion simple possible seulement pour les molécules hydrophobes assez petites. L'exemple le plus simple est celui de l'oxygène. Les gaz dissous dans le sang traversent presque tous la BHE : oxygène, mais aussi CO2, azote, etc. Mais cela concerne aussi des molécules. Par exemple, l'éthanol (l'alcool) traverse les membranes cellulaire sans aucun problème, y compris la BHE. Comme autre exemple, les stéroïdes traversent les membranes cellulaires sans problèmes, et cela vaut aussi bien pour les hormones sexuelles que le cortisol. Hormones sexuelles et cortisol rentrent dans le cerveau sans problème, ils traversent la barrière hémato-encéphalique.
Par contre, des molécules importantes ne passent pas diffusion libre : l'eau, le glucose, les acides aminés, les protéines, les ions. Vous avez bien lu : l'eau ne traverse pas les membranes cellulaires. Et c'est évident, car l'eau est hydrophile. Le glucose est aussi concerné car il se dissous bien dans l'eau, et vous pouvez le constater en mettant du sucre dans de l'eau. Les ions sont naturellement dissous dans l'eau, donc hydrophiles. Les acides aminés et protéines sont soit trop gros, soit trop hydrophiles pour traverser la membrane cellulaire.
Les molécules hydrophiles ne peuvent pas traverser les membranes cellulaires avec la diffusion simple, pas plus que les très grosses molécules. Les ions sont concernés, car les ions étant par nature dissous dans l'eau, ils sont hydrophiles. Les molécules hydrophiles doivent donc traverser les membranes cellulaires par un autre mécanisme. Voyons lesquels.
===Les transporteurs transmembranaires===
Les molécules hydrophiles peuvent traverser les membranes cellulaires grâce à des protéines transmembranaires, à savoir qui traversent la membrane cellulaire de part en part. Une partie de ces protéines transmembranaires permettent à une molécule de traverser la membrane cellulaire, d'où leur nom de '''transporteurs transmembranaires''', simplifié en transporteurs. Nous avons déjà vu des transporteurs transmembranaires dans ce cours : les pompes et canaux ioniques en sont ! Ce sont juste des transporteurs spécialisées dans le transport des ions, comme on aurait pu le deviner. Les petites et grosses molécules utilisent des transporteurs différents des canaux et pompes ioniques, mais qui fonctionnent sur le même principe.
Il existe des '''transporteurs actifs''', qui demandent de l'énergie sous forme d'ATP pour fonctionner. Ils peuvent fonctionner même en l'absence de gradient de concentration, voire fonctionner même si le gradient de concentration est défavorable. La plupart des transporteurs actifs de la BHE visent à rejeter des molécules indésirables dans le sang. Par exemple, de nombreuses pompes ioniques visent à rejeter des ions dans le sang. Si ces ions rentrent dans la cellule endothéliale, ils seront rejetés par ces pompes. Les pompes en question portent des noms assez complexes, la plus connue étant ceux de la famille des ''Organo anion transporters''. Et il existe aussi des pompes non-ioniques, pour des molécules indésirables plus grosses. Un exemple est celui de la ''Glycoprotéine P'', qui empeche de nombreux médicaments de traverser la BHE, sans compter qu'elle rejette aussi de nombreuses molécules organiques.
Si les transporteurs actifs sont des pompes qui rejettent des molécules dans le sang, d'autres transporteurs servent au contraire à faciliter l'entrée de molécules dans le cerveau. La plupart sont des '''transporteurs passifs''', à savoir qu'ils demandent un gradient de concentration pour fonctionner. Ils servent en quelque sorte de sas d'entrée, qui permet aux molécules de traverser la membrane si le gradient de concentration est favorable. Les molécules suivent le gradient de concentration et vont vers la zone la moins concentrée, mais seulement si le sas d'entrée/transporteur est ouvert. En quelque sorte, ce sont l'équivalent des canaux ioniques ouvert/fermés mais pour les grosses molécules. Pour distinguer ce mécanisme de transport de la diffusion simple, on dit que les canaux ioniques et transporteurs font de la '''diffusion facilitée''', dans le sens où le transporteur/canal ionique facilite la traversée de la membrane.
Le processus d'entrée via un transporteur se fait en quelques étapes. Pour commencer, la molécule va se lier au transporteur, de la même manière qu'un neurotransmetteur se lie à son récepteur. Suite à cette liaison, le transporteur va être déstabilisé par diverses interactions électriques avec la molécule. Il va changer de forme et se reconfigurer. Cette reconfiguration fait que la molécule, auparavant sur la face extérieure, se retrouve sur la face intérieure de la membrane cellulaire. Enfin, la molécule se détache : elle a traversé la membrane.
[[File:Scheme facilitated diffusion in cell membrane-fr.svg|centre|vignette|upright=2.5|Diffusion facilitée : canal ionique à gauche, transporteurs à droite.]]
Les transporteurs servent de portes d'entrées qui permettent le passage des molécules à travers une membrane cellulaire. En conséquence, ils sont finement régulés de manière à ne laisser les molécules qu'avec parcimonie, suffisamment pour ne pas avoir de déficience cérébrale, mais pas assez pour perturber le fonctionnement cérébral. Par exemple, le passage des ions est sévèrement contrôlé, afin de protéger le cerveau des variations de concentration ionique du sang. Par exemple, suite à un repas trop salé, la concentration intracérébrale en sodium doit rester la même, le sodium ne doit pas traverser la barrière-hémato-encéphalique. A l'inverse, lors d'un manque de sodium intra-cérébral, la barrière hémato-encéphalique laissera passer ces ions sodium. La régulation de l'équilibre ionique du cerveau est assez simple : il suffit d'ouvrir ou de fermer des canaux ioniques selon les besoins.
===Le transport vésiculaire===
Il existe aussi un dernier mode de transport, appelé le '''transport vésiculaire''', qui est particulièrement adapté au transport de grosses molécules. Avec lui, les molécules peuvent traverser une cellule endothéliale en un seul passage, sans avoir à traverser de membrane cellulaire proprement dit. Les molécules sont transportées à travers la barrière hémato-encéphalique dans un sac de lipides appelé une vésicule. La vésicule se forme par invagination de la membrane cellulaire, qui se replie sur elle-même pour former une vésicule. La vésicule traverse alors la cellule endothéliale, puis se colle sur la membrane de l'autre côté. Elle fusionne alors avec la membrane, avec l'aide d'un paquet d'enzymes. La fusion relâche le contenu de la vésicule dans le milieu ambiant, dans le cerveau.
[[File:Vesicle Budding, Motility and Fusion.jpg|centre|vignette|upright=2|Transport par vésicules intra-cellulaires. La vésicule se forme à gauche par invagination de la membrane cellulaire, la vésicule traverse la cellule, puis fusionne avec la membrane de l'autre côté.]]
==L'entrée des molécules dans le cerveau : quelques exemples==
La BHE laisse passer certaines molécules via l'intermédiaire de transporteurs, du transport vésiculaire et de la diffusion simple. Outre les canaux ioniques, la BHE dispose de transporteurs passifs pour le glucose, mais aussi pour l'eau (des aquaporines), ainsi que pour les acides aminés essentiels. Dans ce qui va suivre, nous allons voir quelques exemples, en étudiant le cas du cuivre et du fer. L'exemple du cuivre aide à faire comprendre comment les transporteurs permettent de faire passer des molécules du sang au cerveau. L'exemple du fer est un cas particulier qui mélange transporteurs et transport vésiculaire.
===L'entrée du cuivre dans le cerveau===
Le premier exemple, que nous allons voir en détail, est le transport du cuivre. Pour rappel, pour traverser la barrière hémato-encéphalique, il faut traverser deux membranes cellulaires : celle entre le sang et la cellule du capillaire sanguin, celle entre le capillaire sanguin et le cerveau. Pour cela, le cuivre a deux transporteurs Le premier permet au cuivre de passer du sang à l'intérieur des cellules des vaisseaux sanguins, il porte le nom de ''High affinity copper uptake protein 1'', aussi appelé CTR1. Le second est le transporteur ATP7A, qui émet le cuivre dans le cerveau. Les mêmes transporteurs permettent au cuivre de rentrer dans les neurones et les cellules gliales. Les deux expriment des transporteurs CTR1 pour faire entrer le cuivre dans les neurones/astrocytes/oligodendrocytes/autres.
En cas d'excès de cuivre dans le cerveau, l'excès est éliminé dans le liquide cérébrospinal ou dans le sang. Les cellules qui font la barrière entre cerveau et méninges disposent pour cela d'un transporteur dédié, le ATP7B, qui émet du cuivre dans le liquide cérébrospinal. Le cuivre en excès dans le liquide cérébrospinal est lui émis dans le sang, grâce là encore avec un transporteur ATP7A situés dans les cellules des vaisseaux sanguins.
[[File:Metabolisme cerebral du cuivre.png|centre|vignette|upright=2.5|Métabolisme cérébral du cuivre]]
===L'entrée du fer dans le cerveau===
Comme pour le cuivre, bien qu'étant un ion simple, l'entrée du fer dans le cerveau ne passe pas par des canaux ioniques ou pompes. La raison est qu'il n'y a presque pas d'ion fer isolés, dissous dans le sang. Le fer absorbé par l'intestin est immédiatement capturé par une molécule de transport, appelée la '''transferrine'''. Elle capture le fer libre, le transporte dans le sang et le relâche au niveau des tissus qui en ont besoin. Il faut noter que l'on distingue l'apo-transferrine et l'holo-transferrine, la première étant de la transferrine seule, l'autre étant de la transferrine qui a capturé du fer. L'apo- et l'holo-transferrine n'ont pas la même forme tridimensionnelle, ce qui a des conséquences.
La transferrine entre dans le cerveau via transport vésiculaire. L'holo-transferrine sanguine se fixe sur des récepteurs à la transferrine, présents à la surface des cellules endothéliales. La fixation sur ces récepteurs entraine l'internalisation de la molécule d'holo-transferrine, la formation de la vésicule. Seule l'holo-transferrine se lie aux transporteurs des cellules endothéliales, pas l'apo-transferrine, pour des raisons de conformation tridimensionnelle.
Ensuite, la vésicule traverse la cellule endothéliale, puis fusionne avec sa membrane de l'autre côté de la cellule, ce qui relâche la transferrine et le fer dans le cerveau. Ce transport vésiculaire basique est la première voie d'entrée du fer dans le cerveau, mais c'est la moins importante en pratique. En effet, il s'agit d'une voie directe, passive, sans mécanisme de régulation poussé. Mais le cerveau a des besoins en fer qui doivent être sérieusement régulés, pour éviter tout excès de Fer. Rappelons en effet qu'un excès de fer est toxique pour les cellules, neurones et cellules gliales comprises. Pour cela, la barrière hémato-encéphalique dispose d'autres moyens de transport du fer, qui sont régulables.
Outre le transport vésiculaire simple, il existe deux autres voies de transport, qui permettent au fer de traverser la barrière hématoencéphalique. Les deux commencent de la même manière : la transferrine est internalisée dans une vésicule. Sauf que le Fer en excès est extrait des vésicules. L'extraction du Fer se fait en deux étapes : la première détache le fer de la transferrine dans la vésicule, la seconde le fait sortir de la vésicule à travers un "canal ionique" appelé le DMT1, inséré à la surface de la vésicule. La vésicule est alors renvoyée vers la paroi du vaisseau sanguin et fusionne avec, le récepteur de la transferrine est ainsi recyclé.
Le Fer extrait de la vésicule est du fer dissous dans la cellule endothéliale. Il peut alors subir deux voies de transfert différentes. La première passe par une protéine de transport membranaire qui relâche le Fer dans le cerveau, qui agit un petit peu comme un "canal ionique". La protéine en question s'appelle la '''ferroportine''', elle est présente dans le cerveau, dans les intestins et d'autres cellules. Il faut noter que le Fer transporté par la ferroportine est toujours un ion <math>Fe^{2+}</math> et non les autres formes ioniques (<math>Fe^{3+}</math> ou <math>Fe^{+}</math>). C'est le contraire de la transferrine qui capture des ions <math>Fe^{3+}</math>.
L'action de la ferroportine est régulée par une molécule appelée l''''hepcidine''', qui a plusieurs actions. Premièrement, elle "ouvre" ou "ferme" le "canal ionique" de la ferroportine. Deuxièmement, elle peut détruire les molécules de ferroportine en les marquant comme prête pour dégradation dans les lysosomes. Elle régule ainsi l'entrée du fer à ce qui est utile au cerveau : s'il subit un excès de Fer, il produira de l'hepcidine pour "désactiver" la ferroportine et séquestrer le fer dans les cellules endothéliales. La production de l'hepcidine cérébrale est le fait des astrocytes.
Une seconde voie séquestre le fer en excès dans la cellule endothéliale, pour être relâché en cas de besoin. Pour cela, les réserves de fer sont stockées dans une molécule appelée la '''ferritine''', une molécule de ferritine pouvant capturer près de 5000 atomes de fer. Pour relâcher le fer, la ferritine est internalisée dans une seconde vésicule, qui fusionne avec la membrane. Les cellules endothéliales ont donc des réserves de fer sous forme de ferritine, qu'elles peuvent relâcher dans le cerveau si le besoin s'en fait sentir. Les mécanismes pour ce faire sont encore mal connus. Le fer est alors absorbé par un astrocyte. Les trois voies posibles sont résumées dans le schéma ci-dessous.
[[File:Fer et barrière hemato-encéphalique.png|centre|vignette|upright=3|Fer et barrière hemato-encéphalique]]
===L'entrée du manganèse dans le cerveau===
Le cas du manganèse est assez similaire à celui du fer. Le manganèse est important pour le métabolisme général e la plupart des cellules. Mais il doit être présent en petites quantités, de fortes quantités pouvant être toxiques. Aussi, comme le fer et le cuivre, son entrée dans le cerveau est fortement régulée.
L'entrée du manganèse dans le cerveau est presque identique à celle du fer. Le manganèse est transporté par la transferrine, comme le fer, et passe à travers le transporteur DMT1 et la ferroportine. Il traverse la barrière hémato-encéphalique par l'intermédiaire des voies d'entrée du fer, donc. Par contre, la barrière hémato-encéphalique dispose aussi de pompes ioniques qui expulsent le manganèse en trop dans le sang. Précisément, la pompe qui expulse le manganèse en trop dans le sang est la protéine SLC30A10.
Le manganèse s'accumule dans le cerveau aux mêmes endroits que le fer : dans les ganglions de la base. Les effets d'une intoxication au manganèse sont donc similaire à ceux d'un excès cérébral de fer : syndrome parkinsonien, autres troubles moteurs comme des dystonies. Une intoxication en manganèse est généralement lié à une exposition professionnelle, mais il existe de rares maladies génétiques liées à des mutations de la protéine SLC30A10 qui entrainent un excès cérébral en manganèse.
<noinclude>
{{NavChapitre | book=Neurosciences
| prev=L'activité électrique du cerveau
| prevText=L'activité électrique du cerveau
| next=Le métabolisme cérébral
| nextText=Le métabolisme cérébral
}}{{autocat}}
</noinclude>
sq186nhdjsdszwk8ea99ckj9l54lf9c
744032
744031
2025-06-03T13:40:24Z
Mewtow
31375
/* L'entrée des molécules dans le cerveau : quelques exemples */
744032
wikitext
text/x-wiki
Le cerveau est séparé de la circulation sanguine, par une '''barrière hémato-encéphalique''' qui isole le cerveau du reste du corps. Elle est présente sur tous les vaisseaux sanguins cérébraux, ou presque. Les molécules sanguines ne peuvent pas toutes passer à travers la barrière hémato-encéphalique : si certaines le peuvent, d'autres ne le peuvent pas. On dit que la barrière hémato-encéphalique a une '''perméabilité sélective'''. C'est d'ailleurs la raison d'être de la barrière hémato-encéphalique : empêcher le passage de "molécules nuisibles" à travers les capillaires, pour éviter qu'elles passent dans le cerveau. Les ions (calcium, potassium, sodium, et autres) ne peuvent pas traverser cette barrière, leur concentration étant importante pour le bon fonctionnement des potentiels d'action. D'autres molécules importantes, comme le glucose ou d'autres nutriments, peuvent la traverser.
Beaucoup de médicaments ne peuvent pas passer la barrière hémato-encéphalique, ce qui est parfois un avantage, parfois un défaut. C'est un défaut si la molécule a le potentiel d'agir positivement sur le cerveau, mais que la barrière hémato-encéphalique lui empêche d'rentrer. Par exemple, imaginons qu'on découvre une molécule thérapeutique qui permettrait d'augmenter la neurogenèse (la fabrication de nouveaux neurones). Si elle passait à travers la barrière hémato-encéphalique, elle pourrait soigner des maladies comme Alzheimer. Mais elle ne servirait à rien si elle ne pouvait pas rentrer dans le cerveau !
À l'inverse, ce peut être un avantage dans certains cas. Par exemple, une molécule noradrénergique utilisée pour traiter les troubles du rythme cardiaque ne devrait, idéalement, pas agir sur le cerveau. Si elle ne passe pas à travers la barrière hémato-encéphalique, tout va pour le mieux : la molécule n'agira pas sur le cerveau, ce qui fait des effets secondaires d'évités. Par contre, si ce n'est pas le cas, cela pourrait causer des effets secondaires neurologiques ou psychiatriques assez graves.
: Avant de poursuivre, sachez que nous utiliserons souvent l'abréviation, BHE pour désigner la barrière hémato-encéphalique.
==La barrière hémato-encéphalique : les capillaires cérébraux==
La barrière hémato-encéphalique se situe au niveau des vaisseaux sanguins du système nerveux, à quelques exceptions. Les exceptions en question sont des vaisseaux où la barrière hémato-encéphalique n'existe pas, mais ils sont très rares. Pour être précis, les vaisseaux sanguins protégés par la barrière hémato-encéphalique sont les capillaires, à savoir les petits vaisseaux suffisamment fins pour laisser passer de l'oxygène, du CO2, des nutriments, quelques petites molécules.
Les capillaires ne doivent pas être confondus avec les artères et les veines, des vaisseaux plus gros. La différence principale est que les capillaires sont composés d'une unique couche de cellules jointes, juxtaposées les unes à côté des autres (l'ensemble forme ce qu'on appelle un épithélium). Les artères ajoutent plusieurs couches en plus de l'épithélium, dont une couche de muscles qui aident à contracter ou dilater l'artère, et un second épithélium qui enveloppe l'artère. Même chose pour les veines, avec cependant des couches en plus. Les artères et veines du système nerveux ne sont pas différentes de celles trouvées dans le reste du corps. Par contre, les capillaires cérébraux sont totalement différents sur deux points.
===Des capillaires continus protégés par des astrocytes===
La barrière hémato-encéphalique est formée par deux choses : le vaisseaux capillaire lui-mêmes, et une couche protectrice d'astrocytes tout autour. La première barrière est liée au fait que les capillaires du système nerveux central sont moins étanches que les capillaires normaux. La seconde barrière est formée par les astrocytes, qui entourent les vaisseau sanguins.
[[File:Blood brain barrier.png|centre|vignette|upright=2|Barrière hémato-encéphalique.]]
Les capillaires peuvent se classer en trois types, suivant la manière dont l’épithélium est formé : capillaires sinusoïdaux, fenêtrés et continus. La différence est que les deux sont perclus de trous, mais pas le dernier.
Dans les '''capillaires sinusoïdaux''', les cellules de l'épithélium sont liées les unes aux autres, grâce à des espèces de crochets moléculaires, mais d'une manière assez lâche. Les crochets attachent les cellules à leurs voisines, mais laissent des espaces entre les cellules, qui peut prendre la forme de fentes. Cela permet de laisser des cellules comme des globules blancs et des globule rouges, des grosses protéines, mais ils peuvent aussi malheureusement laisser passer les virus et quelques toxines.
[[File:202104 Sinusoidal capillary.svg|centre|vignette|upright=1|Capillaires sinusoïdal.]]
Les capillaires fenêtrés et continus ont des cellules collées les unes aux autres par des jonctions communicantes, les mêmes que celles qui servent pour les synapses électriques. Elles sont beaucoup plus nombreuses que les crochets moléculaires des capillaires normaux, ce qui colle mieux les cellules du capillaire. En conséquence, les espaces entre cellules n'existent pas, ce qui ne permet pas de laisser passer des cellules, virus ou protéines. Mais par contre, les '''capillaires fenêtrés''' ont des pores qui traversent les cellules du vaisseaux sanguin et permettent de laisser passer de grosses molécules, comme des protéines, mais aussi des virus et autres bactéries de petite taille.
[[File:202104 Fenestrated capillary.svg|centre|vignette|upright=1|Capillaires fenêtré.]]
Les '''capillaires continus''' ne laissent ni espace entre cellules, ni pore, ni quoique ce soit. En conséquence, ils sont vraiment imperméables, au point qu'ils fournissent une première barrière contre les agressions extérieures, notamment contre les virus, les bactéries ou les toxines de grande taille. De tel capillaires ne se trouvent qu'en deux endroits : les muscles squelettiques, et le système nerveux. La première barrière hémato-encéphalique est le fait que tous les capillaires du système nerveux sont des capillaires continus.
[[File:202104 Continuous capillary.svg|centre|vignette|upright=1|Capillaires continu.]]
Ensuite, la seconde couche est formée par les astrocytes qui entourent les vaisseaux. Plus précisément, les capillaires cérébraux ne sont pas entourés de muscles, comme leurs congénères, mais sont recouverts par des astrocytes et des péricytes. Le tout forme une double couche : une première couche formé par le vaisseau sanguin, une seconde couche formée par astrocytes. Une molécule qui souhaite passer cette barrière doit donc travers plus d'obstacles qu'ailleurs. La protection est meilleure comparé à la paroi d'un vaisseau sanguin, les membranes astrocytaires faisant office de renforcement.
[[File:Blood Brain Barrier.jpg|centre|vignette|upright=2.0|Barrière hémato-encéphalique]]
===Les espaces périvasculaires/de Virchow–Robin===
Outre la barrière hémato-encéphalique, certains vaisseaux sanguins cérébraux sont entourés par un manchon rempli de fluide : l''''espace périvasculaire''', aussi appelé ''espace de Virchow–Robin''. Ils sont surtout présents sur les vaisseaux des méninges, dans les couches situées sous l'arachnoïde et la pie-mère. Mais on en trouve aussi autour des vaisseaux des ganglions de la base et de quelques autres vaisseaux. Le cas le plus important est de loin les vaisseaux qui alimentent les organes circumventriculaires, à savoir des aires cérébrales qui ne sont pas protégées par la barrière hémato-encéphalique, sans doute pour assurer leur protection immunologique. On en trouve aussi en dehors du cerveau, dans le foie, le thymus et quelques autres organes, mais ceci est une autre histoire...
Les espaces périvasculaires possèdent des fonctions variées, qui vont de l'immunité à la régulation du transfert d'ions et de solutés. Ils servent, entre autres, de protection immunologique. Preuve en est la présence d'un grand nombre de globules blancs, surtout lors d'infections : lymphocytes T et B, monocytes, etc. Mais leur rôle principal est la régulation des échanges de fluide avec le cerveau, de permettre un échange sang<->cerveau aisé des solutés, ions et autres petites molécules solubles.
==Le passage des molécules à travers la barrière hémato-encéphalique : généralités==
La barrière hémato-encéphalique est donc composée des capillaires sanguins du cerveau, parfois recouvert par une couche d'astrocytes. Les cellules des capillaires sanguins sont appelées des '''cellules endothéliales'''. Les molécules doivent donc traverser une couche de cellules endothéliales, pour ensuite être absorbée par les astrocytes qui s'occupent de métaboliser ce qui doit l'être. La traversée d'une cellule endothéliale demande que la molécule traverse la membrane de cette cellule deux fois : une fois pour le passage sang->cellule endothéliale, une seconde fois pour sortir de la cellule endothéliale et rentrer dans le cerveau. Il y a donc deux passages à travers une membrane cellulaire, et cela nous amène à parler plus en détail des membranes cellulaires.
Il existe divers mécanismes qui permettent à des molécules de traverser les membranes cellulaires des cellules endothéliales. Les mécanismes sont résumés dans le schéma ci-dessous. Nous allons les détailler un par un dans ce qui suit, mais les images devraient vous donner une petite idée de comment fonctionnent ces mécanismes.
[[File:Blood-brain barrier transport en.png|centre|vignette|upright=2.5|Mécanismes de transport à travers la barrière hémato-encéphalique.]]
Les mécanismes en question peuvent se classer en deux types. Les mécanismes de '''transport passifs''' utilisent un gradient de concentration, ce qui permet de faire passer une molécule d'un endroit où elle est très concentrée vers un endroit où elle l'est moins. Ils ne consomment pas d'énergie. A l'inverse, les mécanismes de '''transport actif''' consomment de l'énergie sous forme d'ATP, mais sont capables d'aller contre un gradient de concentration. Pour donner un exemple, les pompes ioniques sont des mécanismes de transport actif, les canaux ioniques sont du transport passif. Il existe une sorte d'équivalent mais pour les molécules plus grosses.
===La diffusion simple et le transport paracellualire===
Les deux mécanismes de transport les plus simples sont le transport paracellulaire et la diffusion simple. Avec ceux deux-là, de très petites molécules peuvent passer la BHE directement, en suivant un gradient de concentration, sans rencontrer d'obstacle digne de ce nom. La BHE est naturellement perméable pour ces petites molécules. Ce sont donc des mécanismes de transport passif.
Le premier est le '''transport paracellulaire''', où les molécules passent entre les cellules endothéliales des capillaires sanguins. Les cellules endothéliales sont certes collées les unes au autres, mais pas parfaitement. Il reste toujours quelques espaces qui peuvent laisser passer de petites molécules. Le terme paracellulaire est assez parlant : para- pour dire à côté, et cellulaire.
Cependant, dans le cerveau, ce mécanisme de transport est rendu impossible. Les capillaires sanguins sont continus, il n'y a pas d'espace entre les cellules endothéliales. Les cellules endothéliales sont collées les unes au autres grâce à des jonctions communicantes et autres jonctions cellulaires, qui ne laissent presque pas d'espace libre. En conséquence, le transport paracellulaire est empêché, ce qui en fait un premier mécanisme de protection, une première barrière hémato-encéphalique. Les molécules du sang doivent donc traverser les cellules endothéliales, pas passer à côté.
Le second mécanisme de transport passif est la '''diffusion simple'''. Avec elle, une molécule passe à travers la barrière hémato-encéphalique sans rencontrer d'obstacles, comme dans du beurre, sans recourir à des canaux ioniques ni quoique ce soit d'équivalent.
[[File:Scheme simple diffusion in cell membrane-fr.svg|centre|vignette|upright=2|Diffusion simple.]]
Pour comprendre quelles molécules peuvent traverser ainsi, faisons un rappel rapide sur les membranes cellulaires, ainsi que sur les molécules hydrophobes et hydrophiles. Les molécules sont souvent classés en molécules hydrophiles (attirées par l'eau) et hydrophobes (repoussées par l'eau). Le caractère hydrophile et hydrophobe est lié à des interactions électriques avec les molécules d'eau. Les molécules hydrophiles sont généralement polaires, avec une charge positive à un côté de la molécule et un côté négatif de l'autre. Par contre, les molécules hydrophobe sont généralement neutres.
Les molécules hydrophiles se dissolvent très bien dans l'eau, alors que les molécules hydrophobes ne se dissolvent pas, et cela n'a rien de surprenant. Par contre, le caractère hydrophobe/hydrophile a aussi un impact sur la dissolution dans des graisses, les lipides. Les molécules hydrophobes sont généralement liposolubles, ce qui veut dire qu'elles se dissolvent dans les graisses, les lipides. A l'inverse, les molécules hydrophiles ne se dissolvent pas dans les graisses.
Et il se trouve que la membrane cellulaire est composée d'une double couche de lipides ! Chaque molécule de lipide a une tête hydrophile (attirée par l'eau), et une queue hydrophobe (qui est repoussée par l'eau). La membrane a donc un cœur hydrophobe et un extérieur hydrophile. Les molécules hydrophiles ne traversent pas le cœur hydrophobe, car elles ne se dissolvent pas dans les lipides. Par contre, les molécules hydrophobes traversent la membrane cellulaire sans trop de soucis. La traversée d'une membrane cellulaire n'est donc pas la même entre une molécule hydrophobe et une molécule hydrophile.
[[File:Fluid Mosaic.svg|centre|vignette|upright=2|Membrane cellaulaire.]]
La diffusion simple n'est donc possible que si la molécule peut se dissoudre dans la membrane cellulaire, donc se dissoudre dans des lipides. En clair, c'est limité aux molécules hydrophobes, liposolubles. De plus, les molécules doivent être assez petites, les grosses molécules ne peuvent pas traverser facilement. Une molécule plus grosse que la membrane aura du mal à se dissoudre dedans.
Pour résumer : diffusion simple possible seulement pour les molécules hydrophobes assez petites. L'exemple le plus simple est celui de l'oxygène. Les gaz dissous dans le sang traversent presque tous la BHE : oxygène, mais aussi CO2, azote, etc. Mais cela concerne aussi des molécules. Par exemple, l'éthanol (l'alcool) traverse les membranes cellulaire sans aucun problème, y compris la BHE. Comme autre exemple, les stéroïdes traversent les membranes cellulaires sans problèmes, et cela vaut aussi bien pour les hormones sexuelles que le cortisol. Hormones sexuelles et cortisol rentrent dans le cerveau sans problème, ils traversent la barrière hémato-encéphalique.
Par contre, des molécules importantes ne passent pas diffusion libre : l'eau, le glucose, les acides aminés, les protéines, les ions. Vous avez bien lu : l'eau ne traverse pas les membranes cellulaires. Et c'est évident, car l'eau est hydrophile. Le glucose est aussi concerné car il se dissous bien dans l'eau, et vous pouvez le constater en mettant du sucre dans de l'eau. Les ions sont naturellement dissous dans l'eau, donc hydrophiles. Les acides aminés et protéines sont soit trop gros, soit trop hydrophiles pour traverser la membrane cellulaire.
Les molécules hydrophiles ne peuvent pas traverser les membranes cellulaires avec la diffusion simple, pas plus que les très grosses molécules. Les ions sont concernés, car les ions étant par nature dissous dans l'eau, ils sont hydrophiles. Les molécules hydrophiles doivent donc traverser les membranes cellulaires par un autre mécanisme. Voyons lesquels.
===Les transporteurs transmembranaires===
Les molécules hydrophiles peuvent traverser les membranes cellulaires grâce à des protéines transmembranaires, à savoir qui traversent la membrane cellulaire de part en part. Une partie de ces protéines transmembranaires permettent à une molécule de traverser la membrane cellulaire, d'où leur nom de '''transporteurs transmembranaires''', simplifié en transporteurs. Nous avons déjà vu des transporteurs transmembranaires dans ce cours : les pompes et canaux ioniques en sont ! Ce sont juste des transporteurs spécialisées dans le transport des ions, comme on aurait pu le deviner. Les petites et grosses molécules utilisent des transporteurs différents des canaux et pompes ioniques, mais qui fonctionnent sur le même principe.
Il existe des '''transporteurs actifs''', qui demandent de l'énergie sous forme d'ATP pour fonctionner. Ils peuvent fonctionner même en l'absence de gradient de concentration, voire fonctionner même si le gradient de concentration est défavorable. La plupart des transporteurs actifs de la BHE visent à rejeter des molécules indésirables dans le sang. Par exemple, de nombreuses pompes ioniques visent à rejeter des ions dans le sang. Si ces ions rentrent dans la cellule endothéliale, ils seront rejetés par ces pompes. Les pompes en question portent des noms assez complexes, la plus connue étant ceux de la famille des ''Organo anion transporters''. Et il existe aussi des pompes non-ioniques, pour des molécules indésirables plus grosses. Un exemple est celui de la ''Glycoprotéine P'', qui empeche de nombreux médicaments de traverser la BHE, sans compter qu'elle rejette aussi de nombreuses molécules organiques.
Si les transporteurs actifs sont des pompes qui rejettent des molécules dans le sang, d'autres transporteurs servent au contraire à faciliter l'entrée de molécules dans le cerveau. La plupart sont des '''transporteurs passifs''', à savoir qu'ils demandent un gradient de concentration pour fonctionner. Ils servent en quelque sorte de sas d'entrée, qui permet aux molécules de traverser la membrane si le gradient de concentration est favorable. Les molécules suivent le gradient de concentration et vont vers la zone la moins concentrée, mais seulement si le sas d'entrée/transporteur est ouvert. En quelque sorte, ce sont l'équivalent des canaux ioniques ouvert/fermés mais pour les grosses molécules. Pour distinguer ce mécanisme de transport de la diffusion simple, on dit que les canaux ioniques et transporteurs font de la '''diffusion facilitée''', dans le sens où le transporteur/canal ionique facilite la traversée de la membrane.
Le processus d'entrée via un transporteur se fait en quelques étapes. Pour commencer, la molécule va se lier au transporteur, de la même manière qu'un neurotransmetteur se lie à son récepteur. Suite à cette liaison, le transporteur va être déstabilisé par diverses interactions électriques avec la molécule. Il va changer de forme et se reconfigurer. Cette reconfiguration fait que la molécule, auparavant sur la face extérieure, se retrouve sur la face intérieure de la membrane cellulaire. Enfin, la molécule se détache : elle a traversé la membrane.
[[File:Scheme facilitated diffusion in cell membrane-fr.svg|centre|vignette|upright=2.5|Diffusion facilitée : canal ionique à gauche, transporteurs à droite.]]
Les transporteurs servent de portes d'entrées qui permettent le passage des molécules à travers une membrane cellulaire. En conséquence, ils sont finement régulés de manière à ne laisser les molécules qu'avec parcimonie, suffisamment pour ne pas avoir de déficience cérébrale, mais pas assez pour perturber le fonctionnement cérébral. Par exemple, le passage des ions est sévèrement contrôlé, afin de protéger le cerveau des variations de concentration ionique du sang. Par exemple, suite à un repas trop salé, la concentration intracérébrale en sodium doit rester la même, le sodium ne doit pas traverser la barrière-hémato-encéphalique. A l'inverse, lors d'un manque de sodium intra-cérébral, la barrière hémato-encéphalique laissera passer ces ions sodium. La régulation de l'équilibre ionique du cerveau est assez simple : il suffit d'ouvrir ou de fermer des canaux ioniques selon les besoins.
===Le transport vésiculaire===
Il existe aussi un dernier mode de transport, appelé le '''transport vésiculaire''', qui est particulièrement adapté au transport de grosses molécules. Avec lui, les molécules peuvent traverser une cellule endothéliale en un seul passage, sans avoir à traverser de membrane cellulaire proprement dit. Les molécules sont transportées à travers la barrière hémato-encéphalique dans un sac de lipides appelé une vésicule. La vésicule se forme par invagination de la membrane cellulaire, qui se replie sur elle-même pour former une vésicule. La vésicule traverse alors la cellule endothéliale, puis se colle sur la membrane de l'autre côté. Elle fusionne alors avec la membrane, avec l'aide d'un paquet d'enzymes. La fusion relâche le contenu de la vésicule dans le milieu ambiant, dans le cerveau.
[[File:Vesicle Budding, Motility and Fusion.jpg|centre|vignette|upright=2|Transport par vésicules intra-cellulaires. La vésicule se forme à gauche par invagination de la membrane cellulaire, la vésicule traverse la cellule, puis fusionne avec la membrane de l'autre côté.]]
==L'entrée des molécules dans le cerveau : quelques exemples==
La BHE laisse passer certaines molécules via l'intermédiaire de transporteurs, du transport vésiculaire et de la diffusion simple. Outre les canaux ioniques, la BHE dispose de transporteurs passifs pour le glucose, mais aussi pour l'eau (des aquaporines), ainsi que pour les acides aminés essentiels. Dans ce qui va suivre, nous allons voir quelques exemples, en étudiant le cas du cuivre et du fer. L'exemple du cuivre aide à faire comprendre comment les transporteurs permettent de faire passer des molécules du sang au cerveau. L'exemple du fer est un cas particulier qui mélange transporteurs et transport vésiculaire.
===L'entrée du cuivre dans le cerveau===
Le premier exemple, que nous allons voir en détail, est le transport du cuivre. Pour rappel, pour traverser la barrière hémato-encéphalique, il faut traverser deux membranes cellulaires : celle entre le sang et la cellule du capillaire sanguin, celle entre le capillaire sanguin et le cerveau. Pour cela, le cuivre a deux transporteurs Le premier permet au cuivre de passer du sang à l'intérieur des cellules des vaisseaux sanguins, il porte le nom de ''High affinity copper uptake protein 1'', aussi appelé CTR1. Le second est le transporteur ATP7A, qui émet le cuivre dans le cerveau. Les mêmes transporteurs permettent au cuivre de rentrer dans les neurones et les cellules gliales. Les deux expriment des transporteurs CTR1 pour faire entrer le cuivre dans les neurones/astrocytes/oligodendrocytes/autres.
En cas d'excès de cuivre dans le cerveau, l'excès est éliminé dans le liquide cérébrospinal ou dans le sang. Les cellules qui font la barrière entre cerveau et méninges disposent pour cela d'un transporteur dédié, le ATP7B, qui émet du cuivre dans le liquide cérébrospinal. Le cuivre en excès dans le liquide cérébrospinal est lui émis dans le sang, grâce là encore avec un transporteur ATP7A situés dans les cellules des vaisseaux sanguins.
[[File:Metabolisme cerebral du cuivre.png|centre|vignette|upright=2.5|Métabolisme cérébral du cuivre]]
===L'entrée du fer dans le cerveau===
Comme pour le cuivre, bien qu'étant un ion simple, l'entrée du fer dans le cerveau ne passe pas par des canaux ioniques ou pompes. La raison est qu'il n'y a presque pas d'ion fer isolés, dissous dans le sang. Le fer absorbé par l'intestin est immédiatement capturé par une molécule de transport, appelée la '''transferrine'''. Elle capture le fer libre, le transporte dans le sang et le relâche au niveau des tissus qui en ont besoin. Il faut noter que l'on distingue l'apo-transferrine et l'holo-transferrine, la première étant de la transferrine seule, l'autre étant de la transferrine qui a capturé du fer. L'apo- et l'holo-transferrine n'ont pas la même forme tridimensionnelle, ce qui a des conséquences.
La transferrine entre dans le cerveau via transport vésiculaire. L'holo-transferrine sanguine se fixe sur des récepteurs à la transferrine, présents à la surface des cellules endothéliales. La fixation sur ces récepteurs entraine l'internalisation de la molécule d'holo-transferrine, la formation de la vésicule. Seule l'holo-transferrine se lie aux transporteurs des cellules endothéliales, pas l'apo-transferrine, pour des raisons de conformation tridimensionnelle.
Ensuite, la vésicule traverse la cellule endothéliale, puis fusionne avec sa membrane de l'autre côté de la cellule, ce qui relâche la transferrine et le fer dans le cerveau. Ce transport vésiculaire basique est la première voie d'entrée du fer dans le cerveau, mais c'est la moins importante en pratique. En effet, il s'agit d'une voie directe, passive, sans mécanisme de régulation poussé. Mais le cerveau a des besoins en fer qui doivent être sérieusement régulés, pour éviter tout excès de Fer. Rappelons en effet qu'un excès de fer est toxique pour les cellules, neurones et cellules gliales comprises. Pour cela, la barrière hémato-encéphalique dispose d'autres moyens de transport du fer, qui sont régulables.
Outre le transport vésiculaire simple, il existe deux autres voies de transport, qui permettent au fer de traverser la barrière hématoencéphalique. Les deux commencent de la même manière : la transferrine est internalisée dans une vésicule. Sauf que le Fer en excès est extrait des vésicules. L'extraction du Fer se fait en deux étapes : la première détache le fer de la transferrine dans la vésicule, la seconde le fait sortir de la vésicule à travers un "canal ionique" appelé le DMT1, inséré à la surface de la vésicule. La vésicule est alors renvoyée vers la paroi du vaisseau sanguin et fusionne avec, le récepteur de la transferrine est ainsi recyclé.
Le Fer extrait de la vésicule est du fer dissous dans la cellule endothéliale. Il peut alors subir deux voies de transfert différentes. La première passe par une protéine de transport membranaire qui relâche le Fer dans le cerveau, qui agit un petit peu comme un "canal ionique". La protéine en question s'appelle la '''ferroportine''', elle est présente dans le cerveau, dans les intestins et d'autres cellules. Il faut noter que le Fer transporté par la ferroportine est toujours un ion <math>Fe^{2+}</math> et non les autres formes ioniques (<math>Fe^{3+}</math> ou <math>Fe^{+}</math>). C'est le contraire de la transferrine qui capture des ions <math>Fe^{3+}</math>.
L'action de la ferroportine est régulée par une molécule appelée l''''hepcidine''', qui a plusieurs actions. Premièrement, elle "ouvre" ou "ferme" le "canal ionique" de la ferroportine. Deuxièmement, elle peut détruire les molécules de ferroportine en les marquant comme prête pour dégradation dans les lysosomes. Elle régule ainsi l'entrée du fer à ce qui est utile au cerveau : s'il subit un excès de Fer, il produira de l'hepcidine pour "désactiver" la ferroportine et séquestrer le fer dans les cellules endothéliales. La production de l'hepcidine cérébrale est le fait des astrocytes.
Une seconde voie séquestre le fer en excès dans la cellule endothéliale, pour être relâché en cas de besoin. Pour cela, les réserves de fer sont stockées dans une molécule appelée la '''ferritine''', une molécule de ferritine pouvant capturer près de 5000 atomes de fer. Pour relâcher le fer, la ferritine est internalisée dans une seconde vésicule, qui fusionne avec la membrane. Les cellules endothéliales ont donc des réserves de fer sous forme de ferritine, qu'elles peuvent relâcher dans le cerveau si le besoin s'en fait sentir. Les mécanismes pour ce faire sont encore mal connus. Le fer est alors absorbé par un astrocyte. Les trois voies posibles sont résumées dans le schéma ci-dessous.
[[File:Fer et barrière hemato-encéphalique.png|centre|vignette|upright=3|Fer et barrière hemato-encéphalique]]
===L'entrée du manganèse dans le cerveau===
Le cas du manganèse est assez similaire à celui du fer. Le manganèse est important pour le métabolisme général e la plupart des cellules. Mais il doit être présent en petites quantités, de fortes quantités pouvant être toxiques. Aussi, comme le fer et le cuivre, son entrée dans le cerveau est fortement régulée.
L'entrée du manganèse dans le cerveau est presque identique à celle du fer. Le manganèse est transporté par la transferrine, comme le fer, et passe à travers le transporteur DMT1 et la ferroportine. Il traverse la barrière hémato-encéphalique par l'intermédiaire des voies d'entrée du fer, donc. Par contre, la barrière hémato-encéphalique dispose aussi de pompes ioniques qui expulsent le manganèse en trop dans le sang. Précisément, la pompe qui expulse le manganèse en trop dans le sang est la protéine SLC30A10.
Le manganèse s'accumule dans le cerveau aux mêmes endroits que le fer : dans les ganglions de la base. Les effets d'une intoxication au manganèse sont donc similaires à ceux d'un excès cérébral de fer : syndrome parkinsonien, autres troubles moteurs comme des dystonies. Une intoxication en manganèse est généralement lié à une exposition professionnelle, mais il existe de rares maladies génétiques liées à des mutations de la protéine SLC30A10 qui entrainent un excès cérébral en manganèse.
<noinclude>
{{NavChapitre | book=Neurosciences
| prev=L'activité électrique du cerveau
| prevText=L'activité électrique du cerveau
| next=Le métabolisme cérébral
| nextText=Le métabolisme cérébral
}}{{autocat}}
</noinclude>
s0vtnlsmgzb9jxl839dn2fi8n3wvab3
Discussion utilisateur:165.169.239.238
3
82425
744099
2025-06-04T09:23:21Z
JackPotte
5426
Page créée avec « {{subst:Test 1}}~~~~ »
744099
wikitext
text/x-wiki
{|class="WSerieH" class="plainlinks" id="vandale" align="center" style="width:100%;margin-bottom:2em;border:1px solid #8888aa;border-right-width:2px;border-bottom-width:2px;background-color:#f7f8ff;padding:5px;text-align:justify"
|-
|[[Image:Nuvola apps important.svg|64px|Arrêtez de vandaliser Wikilivres !]]
|Bonjour {{BASEPAGENAME}},
Vous avez découvert combien il est facile de modifier Wikilivres. Votre modification a été '''annulée''' en raison de son caractère non constructif. Merci de ne pas réitérer ce genre de contribution. Visitez la [[Aide:Accueil|page d’aide]] afin d’en apprendre plus ou le [[Wikilivres:bac à sable|bac à sable]] afin de faire des tests.
|}
[[Catégorie:Vandales avertis]][[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) 4 juin 2025 à 11:23 (CEST)
6zize7qvjeaxo9g2810wzmnw2xv799b