Fonctionnement d'un ordinateur/Les processeurs superscalaires
Les processeurs vus auparavant ne peuvent émettre au maximum qu'une instruction par cycle d'horloge : ce sont des processeurs à émission unique. Et quand on court après la performance, ce n'est pas assez ! Les concepteurs de processeurs ont inventés des processeurs qui émettent plusieurs instructions par cycle : les processeurs à émissions multiples.
Les processeurs à émission multiple sont de deux types : les processeurs VLIW et les processeurs superscalaires. Nous mettons les processeurs VLIW de côté en attendant le prochain chapitre. La raison est que ces derniers ont un jeu d'instruction spécialisé qui expose le parallélisme d'instruction directement au niveau des instructions, là où les processeurs superscalaires restent des processeurs au jeu d'instruction normal.
Un processeur superscalaire exécute plusieurs instructions en parallèle. Il charge plusieurs instructions en même temps, les décode en parallèle, puis les exécute sur des unités de calculs séparées. De plus, il faut gérer les dépendances entre instructions, répartir les instructions sur différentes unités de calcul, et cela n'est pas une mince affaire. Certains processeurs superscalaires n'utilisent pas l'exécution dans le désordre, tandis que d'autres le font. La différence principale entre les deux est qu'un processeur superscalaire répartit les instructions sur les unités de calcul à l’exécution, là où un processeur VLIW délègue cette tâche au compilateur.
Les processeurs superscalaires : vision globale de leur implémentation
[modifier | modifier le wikicode]Sur un processeur à émission multiple, plusieurs micro-opérations sont émises en même temps, le nombre varie d'un processeur à l'autre. Les processeurs superscalaires les plus simples ne permettent que d'émettre deux micro-opérations à la fois, d'où leur nom de processeur dual issue, ce qui se traduit en processeur à double émission. Les processeurs modernes peuvent émettre 3, 4, 6, voire 8 micro-opérations simultanément. Aller au-delà ne sert pas à grand-chose.
Il faut noter que la plupart des processeurs superscalaires ont des contraintes sur la manière dont sont émises les instructions. Si on prend un processeur dual-issue, il y a donc des paires d'instructions autorisées et des paires interdites. Par exemple, ils autorisent l'émission simultanée de deux instructions entières, voire flottantes. Mais pour ce qui est des accès mémoire, c'est autre chose. Les CPU superscalaires simples ne gèrent qu'un accès mémoire à la fois, ce qui fait qu'ils ne peuvent pas émettre deux accès mémoire simultanés. Les accès mémoire sont alors sérialisés, exécutés l'un après l'autre. Par contre, émettre un accès mémoire en même temps qu'un calcul est généralement faisable.
Un problème similaire a lieu pour les branchements. Il faut notamment éviter le cas où une instruction exécutée en parallèle du branchement l'est par erreur, en cas de mauvaise prédiction de branchement. Typiquement, les branchements sont souvent sérialisés, dans le sens où le processeur ne peut pas émettre un branchement avec une autre instruction. Les branchements sont émis seuls, le processeur ne profite pas de l'émission multiple au moment d'émettre un branchement, comme pour les accès mémoire. Des processeurs incorporent des optimisations pour éviter cela, mais cela rend le processeur plus complexe, même si le gain en performance est toujours bon à prendre.
L'implémentation, dans les grandes lignes
[modifier | modifier le wikicode]Un processeur superscalaire doit être capable de : lire plusieurs instructions depuis la mémoire, les décoder, renommer leurs registres, et les envoyer à l'unité d'émission. Intuitivement, on se fit qu'on doit dupliquer tous les circuits : les décodeurs, l'unité de renommage, les unités de calcul, le ROB, etc. Dans les faits, l'implémentation d'un processeur superscalaire demande de dupliquer plusieurs circuits et d'en adapter d'autres. Les décodeurs sont dupliqués, les ALU sont déjà présentes en plusieurs exemplaires mais ajouter des ALU est une bonne chose. Les autres circuits sont adaptés pour gérer plusieurs instructions à la fois. Ils ne sont pas dupliqués, car il peut y avoir des dépendances entre instruction chargées simultanément. Voici ce que cela donne dans les grandes lignes.
Processeur sans émission multiple | Chargement | Décodage | Renommage | Émission | Exécution / ALU | Commit/ROB |
---|---|---|---|---|---|---|
Exécution / ALU | ||||||
Processeur superscalaire | Chargement | Décodage | Renommage | Émission | Exécution / ALU | Commit/ROB |
Exécution / ALU | ||||||
Décodage | Exécution / ALU | |||||
Exécution / ALU |
Un processeur superscalaire doit pouvoir charger plusieurs instructions en même temps. Deux solutions pour cela. La première est de doubler la taille du bus connecté au cache d'instruction. Si les instructions sont de longueur fixe, cela charge deux instructions à la fois. Pour un processeur à triple émission, il faut tripler la taille du bus, quadrupler pour un processeur quadruple émission, etc. Mais tout n'est pas si simple, quelques subtilités font qu'on doit ajouter des circuits en plus pour corriger les défauts peu intuitifs de cette implémentation naïve. Une autre solution est d'utiliser un cache d'instruction multiport. Mais dans ce cas, le program counter doit générer deux adresses, au mieux consécutives, au pire prédites par l'unité de prédiction de branchement. L'implémentation est alors encore plus complexe, comme on le verra plus bas.
Il doit ensuite décoder plusieurs instructions en même temps. Il se trouve que les dépendances entre instruction ne posent pas de problème pour le décodeur. Rien ne s'oppose à ce qu'on utilise plusieurs décodeurs séparés.
Ensuite, l'unité d'émission doit être modifiée de manière à émettre plusieurs instructions à la fois. Si c'est un scoreboard, il doit être modifié pour détecter les dépendances entre les instructions à émettre et les instructions déjà dans le pipeline. De plus, il faut aussi qu'il détecte les dépendances entre instruction à émettre, entre elles. Si c'est une fenêtre d'instruction ou une station de réservation, les choses sont plus simples, il faut basiquement rajouter des ports de lecture et écriture pour insérer et émettre plusieurs instructions à la fois, et modifier la logique de sélection en conséquence. Le ROB et les autres structures doivent aussi être modifiées pour pouvoir émettre et terminer plusieurs instructions en même temps.
Pour émettre plusieurs instructions en même temps, encore faut-il avoir de quoi les exécuter. En clair : un processeur superscalaire doit avoir plusieurs unités de calcul séparées. Par unités de calcul, on veut parler des unités de calcul proprement dit, mais aussi les unités d'accès mémoire et de branchement. Un autre terme plus correct serait le terme d'avals, que nous avions introduit dans le chapitre sur les pipelines dynamiques, mais n'avons pas eu l'occasion d'utiliser par la suite.
L'étape de chargement superscalaire
[modifier | modifier le wikicode]Lire des instructions sur un processeur superscalaire peut paraitre simple. La méthode la plus simple consiste à doubler, tripler ou quadrupler la taille du bus mémoire. Précisément, c'est le port de lecture du cache d’instruction qui est élargit, pour lire 2/3/4/... fois plus d'instruction en même temps. Sur un processeur avec des instructions de longueur fixe, la méthode marche très bien. Mais sur les CPU CISC, comme les CPU x86 des PC modernes, c'est une autre paire de manches, car on doit gérer des instruction de taille variable.
Gérer des instructions de taille variable en chargeant plusieurs instructions à la fois est un vrai défi. Il faut découper le bloc chargé en plusieurs instructions, délimiter les frontières de chaque instruction dans le bloc. Le circuit de détection des tailles d'instruction vu dans le chapitre sur l'unité de chargement est donc fortement modifié. Sur le principe, il est dupliqué : on détecte la première instruction, décale le résultat pour récupérer le reste du bloc, puis on recommence tant que le bloc ne contient plus d'instructions. Le résultat est que cela prend parfois un étage de pipeline entier, c'est le cas sur les processeurs Intel de microarchitecture P6.
Mais laissons de côté cette difficulté, et passons au vrai problème du chargement des instructions sur un CPU superscalaire. Charger un gros bloc de mémoire permet de charger plus d'instructions à la fois, mais il y a potentiellement des branchements dans le bloc. Et on doit gérer le cas où ils sont pris, le cas où les instructions suivantes dans le bloc doivent être annulées. En clair, il faut détecter les branchements dans le bloc chargé et gérer le cas où ils sont pris. Dans ce qui va suivre, un morceau de code sans branchement est appelé un bloc de base (basic block).
Le circuit de fusion de blocs
[modifier | modifier le wikicode]Les processeurs superscalaires simples ne se préoccupent pas des branchements lors du chargement. Les instructions chargées en même temps sont toutes décodées et exécutées en même temps, même s'il y a un branchement dans le tas. Les branchements sont donc prédits comme étant non-pris systématiquement. Mais d'autres sont plus malins et partent du principe que le branchement est pris : ils exécutent toutes les instructions d'un bloc, sauf celles qui suivent un branchement pris. L'unité de chargement coupe le bloc chargé au niveau du premier branchement non-pris, remplit les vides avec des NOP, avant d'envoyer le tout à l'unité de décodage. Le processeur détermine quels branchements sont pris ou non avec la prédiction de branchements.
D'autres chargent les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles (exécutées). Le principe peut se généraliser avec un nombre de blocs supérieur à deux.
Ces processeurs utilisent des unités de prédiction de branchement capables de prédire plusieurs branchements par cycle, au minimum l'adresse du bloc à charger et la ou les adresses de destination des branchements dans le bloc. De plus, on doit charger deux blocs de mémoire en une fois, via des caches d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (merger). Le résultat en sortie du fusionneur est ce qu'on appelle une trace.
Le cache de traces
[modifier | modifier le wikicode]Si jamais un bloc est rechargé et que ses branchements sont pris à l'identique, le résultat du fusionneur sera le même. Il est intéressant de conserver cette trace dans un cache de traces pour la réutiliser ultérieurement. Mais il reste à déterminer si une trace peut être réutilisée.
Une trace est réutilisable quand le premier bloc de base est identique et que les prédictions de branchement restent identiques. Pour vérifier cela, le tag du cache de traces contient l'adresse du premier bloc de base, la position des branchements dans la trace et le résultat des prédictions utilisées pour construire la trace.
Le résultat des prédictions de branchement utilisées pour construire la trace est stocké sous la forme d'une suite de bits : si la trace contient n branchements, le n-ième bit vaut 1 si ce branchement a été pris, et 0 sinon. Même chose pour la position des branchements dans la trace : le bit numéro n indique si la n-ième instruction de la trace est un branchement : si c'est le cas, il vaut 1, et 0 sinon.
Pour savoir si une trace est réutilisable, l'unité de chargement envoie l'adresse de chargement au cache de traces, le reste des informations étant fournie par l'unité de prédiction de branchement. Si le tag est identique, alors on a un succès de cache de traces, et la trace est envoyée directement au décodeur. Sinon, la trace est chargée depuis le cache d'instructions et assemblée.
Certains caches de traces peuvent stocker plusieurs traces différentes pour une même adresse de départ, avec une trace par ensemble de prédiction. Mais d'autres caches de traces n'autorisent qu'une seule trace par adresse de départ, ce qui est sous-optimal. Mais on peut limiter la casse on utilisant des correspondances partielles. Si jamais les prédictions de branchement et la position des branchements n'est pas strictement identique, il arrive quand même que les premières prédictions et les premiers branchements soient les mêmes. Dans ce cas, on peut alors réutiliser les blocs de base concernés et le processeur charge les portions de la trace qui sont valides depuis le cache de traces. Une autre solution consiste à reconstituer les traces à la volée. Il suffit de mémoriser les blocs de base dans des caches dédiés et les assembler par un fusionneur. Par exemple, au lieu d'utiliser un cache de traces dont chaque ligne peut contenir quatre blocs de base, on va utiliser quatre caches de blocs de base. Cela permet de supprimer la redondance que l'on trouve dans les traces, quand elles se partagent des blocs de base identiques, ce qui est avantageux à mémoire égale.
La présence d'un cache de traces se marie très bien avec une unité de prédiction de branchement capable de prédire un grand nombre de branchements par cycle. Malheureusement, ces unités de prédiction de branchement sont très complexes et gourmandes en circuits. Les concepteurs de processeurs préfèrent utiliser une unité de prédiction de branchement normale, qui ne peut prédire l'adresse que d'un seul bloc de base. Pour pouvoir utiliser un cache de traces avec une unité de prédiction aussi simple, les concepteurs de processeurs vont ajouter une seconde unité de prédiction, spécialisée dans le cache de traces.
Le séquenceur d'un processeur superscalaire
[modifier | modifier le wikicode]Le séquenceur d'un processeur superscalaire est modifié, afin de pouvoir décoder plusieurs instructions à la fois. Ce n'est pas le cas général, mais la présence de plusieurs décodeurs est très fréquent sur les processeur superscalaires. De plus, les unités de renommage et d'émission doivent être modifiées.
Les décodeurs d'instructions superscalaires
[modifier | modifier le wikicode]Un processeur superscalaire contient généralement plusieurs décodeurs, chacun pouvant décoder une instruction en parallèle des autres. Prenons par exemple un processeur RISC dont toutes les instructions font 32 bits. Un processeur superscalaire de ce type peut charger des blocs de 128 bits, ce qui permet de charger 4 instructions d'un seul coup. Et pour les décoder, le décodage se fera dans quatre décodeurs séparés, qui fonctionneront en parallèle. Ou alors, il se fera dans un seul décodeur qui pourra décoder plusieurs instructions par cycles.
La seconde méthode facilite l'implémentation de la technique de macro-fusion, qui permet au décodeur de fusionner une suite de deux-trois instructions 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. Ou encore, fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Cette technique n'est pas exclusive aux processeurs superscalaires et fonctionne si tout processeur avec un pipeline, mais elle fonctionne au mieux sur ces processeurs, du fait de leur capacité à charger plusieurs instructions à la fois.
Pour donner un exemple assez particulier, prenons le cas des processeurs RISC-V. Sur ce jeu d'instruction, il existe des instructions longues de 32 bits et des instructions courtes de 16 bits. Le chargement des instructions se fait par blocs de 32 bits, ce qui permet de charger une instruction de 32 bits, ou deux instructions de 16 bits. Pour simplifier, supposons que les instructions sont alignées sur des blocs de 32 bits, ce qui signifie qu'un bloc chargé contient soit une instruction de 32 bits, soit deux instructions de 16 bits. On ne tient pas compte des cas où une instruction de 16 bit est immédiatement suivie par une instruction de 32 bits à cheval sur deux blocs. Il est possible de créer un processeur RISC-V partiellement superscalaire en utilisant deux voies : une avec un décodeur pour les instructions 32 bits et une autre voie contenant deux décodeurs pour les instructions de 16 bits. Ainsi, lors du chargement d'un bloc de deux instructions courtes, les deux instructions sont chacune décodées par un décodeur séparé.
Il est même possible de décoder les instructions dans les deux voies en parallèle, avant de choisir quel est celui qui a raison. Sans cette méthode, on doit identifier si le bloc contient une instruction longue ou deux instructions courtes, avant d'envoyer les instructions au décodeur adéquat. Mais avec cette méthode, l'identification du nombre d'instruction se fait en parallèle du décodage proprement dit. Évidemment, une des deux voies donnera des résultats aberrants et totalement faux, mais l'autre donnera le bon résultat. Il suffit de choisir le bon résultat en sortie avec un multiplexeur.
L'unité de renommage superscalaire
[modifier | modifier le wikicode]Sur un processeur à émission multiple, l'unité de renommage de registres doit renommer plusieurs instructions à la fois, mais aussi gérer les dépendances entre instructions. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat.
Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante.
Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande.
L'unité d'émission superscalaire
[modifier | modifier le wikicode]Pour émettre plusieurs instructions en même temps, l'unité d'émission doit être capable d'émettre plusieurs instructions par cycle. Et Pour cela, elle doit détecter les dépendances entre instructions. Il faut noter que la plupart des processeurs superscalaires utilisent le renommage de registre pour éliminer un maximum de dépendances inter-instructions. Les seules dépendances à détecter sont alors les dépendances RAW, qu'on peut détecter en comparant les registres de deux instructions consécutives. Mais le problème des dépendances RAW demeure.
Sur les processeurs superscalaires à exécution dans l’ordre, il faut aussi gérer l'alignement des instructions dans la fenêtre d'instruction. Dans le cas le plus simple, les instructions sont chargées par blocs et on doit attendre que toutes les instructions du bloc soient émises pour charger un nouveau bloc. Avec la seconde méthode, La fenêtre d'instruction fonctionne comme une fenêtre glissante, qui se déplace de plusieurs crans à chaque cycle d'horloge.
Avant toute chose, précisons que le design de l'unité d'émission change. Avant, elle recevait une micro-opération sur son entrée, et fournissait une micro-opération émise sur une sortie. Et cela vaut aussi bien pour une unité d'émission simple, un scoreboard, une fenêtre d'instruction ou des stations de réservation. Mais avec l'émission multiple, les sorties et entrées sont dupliquées. Pour la double émission, il y a deux entrées vu qu'elle doit recevoir deux micro-opération décodées/renommées, et deux sorties pour émettre deux micro-opérations. Formellement, il est possible de faire une analogie avec une mémoire : l'unité d'émission dispose de ports d'écriture et de lecture. On envoie des micro-opérations décodées/renommées sur des ports d'écriture, et elle renvoie des micro-opérations émises sur le port de lecture. Dans ce qui suit, nous parlerons de ports de décodage et de ports d'émission.
Si l'unité d'émission est un vulgaire scoreboard, il doit détecter les dépendances entre instructions émises simultanément. De plus, il doit détecter les paires d'instructions interdites et les sérialiser. Autant dire que ce n'est pas très pratique. La détection des dépendances entre instructions consécutives est simplifiée avec une fenêtre d'instruction, il n'y a pour ainsi dire pas grand chose à faire, vu que les dépendances sont éliminées par le renommage de registre et que les signaux de réveil s'occupent de gérer les dépendances RAW. C'est la raison pour laquelle les processeurs superscalaires utilisent tous une fenêtre d'instruction centralisée ou décentralisée, et non des scoreboard.
Les processeurs superscalaires privilégient souvent des stations de réservations aux fenêtres d'instruction. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d'instruction se contentent de mémoriser la micro-opération à émettre et quelques bits pour la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Les registres sont lus après émission avec une fenêtre d'instruction, avant avec des stations de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'en suit. La différence principale est liée au banc de registre, et précisément au nombre de ports de lecture.
Supposons que toutes les instructions sont dyadiques, ou du moins qu'il n'existe pas de micro-opération à trois opérandes. Avec une fenêtre d'instruction, le nombre d'opérandes à lire simultanément est proportionnel à la quantité d'instructions qu'on peut émettre en même temps. Sur un processeur dual issue, qui peut émettre deux micro-opérations, cela fait deux micro-opérations à deux opérandes chacune, soit 4 opérandes. Le nombre de ports de lecture est donc de quatre. Avec une station de réservation, la lecture des opérandes a lieu avant l'émission, juste après le décodage/renommage. Le nombre d'opérande est le double du nombre de micro-opérations décodées/renommées, pas émises. Si le décodeur peut décoder/renommer 4 instructions par cycle, cela veut dire 4 micro-opérations émises par cycle.
Et il se trouve que les deux situations ne sont pas évidentes. Avec une fenêtre d'instruction centralisée, cela ne change rien. Le nombre d'instructions décodées et émises en même temps sont identiques. Mais dès qu'on utilise des fenêtres d'instruction décentralisées, les choses changent. Si le décodeur peut décoder/renommer 4 instructions par cycle, alors l'idéal est d'avoir 4 micro-opérations émises par cycle, par fenêtre d'instruction. Imaginez que le décodeur décode 4 instructions entières : la fenêtre d'instruction entière doit pouvoir émettre 4 micro-opérations entières en même temps. Idem pour des instructions flottantes avec la fenêtre d'instruction flottantes. Vu qu'il y a deux fenêtres d'instruction, cela fait 4 micro-opérations entières + 4 micro-opérations flottantes = 8 ports de lecture. Non pas qu'ils soient tous simultanément utiles, mais il faut les câbler.
Les fenêtres d'instruction impliquent plus de lectures d'opérandes, ce qui implique plus de ports de lecture. Les stations de réservation sont plus économes, elles vont bien avec un nombre modéré de ports de lecture.
Les conséquences sur le banc de registre
[modifier | modifier le wikicode]Émettre plusieurs instructions en même temps signifie lire ou écrire plusieurs opérandes à la fois : le nombre de ports du banc de registres doit être augmenté. De plus, le banc de registre doit être relié à toutes les unités de calcul en même temps, ce qui fait 2 ports de lecture par unité de calcul. Et avec plusieurs dizaines d'unités de calcul différentes, le câblage est tout simplement ignoble. Et plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Mais diverses optimisations permettent de réduire le nombre de ports assez simplement.
Un autre solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Pour cela, le processeur doit détecter quand il n'y a pas assez de ports pour servir toutes les instructions : l'unité d'émission devra alors mettre en attente certaines instructions, le temps que les ports se libèrent. Cette détection est réalisée par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’arbitre du banc de registres (register file arbiter).
Les unités de calcul des processeurs superscalaires
[modifier | modifier le wikicode]Disposer de plusieurs unités de calcul est tout sauf compliqué, les processeurs avec un pipeline dynamique sont dans ce cas. Ils incorporent typiquement de plusieurs unités pour les instructions entières, d'une unité pour les instructions flottantes, d'une unité pour les accès mémoire (calcul d'adresse inclus) et éventuellement d'une unité pour les tests/branchements.
Les processeurs superscalaires les plus simples ne font qu'exploiter au mieux ces unités de calcul existantes. Les processeurs avec un pipeline dynamique sont parfaitement capables d'exécuter des calculs indépendants dans des unités de calcul séparées. Mais il faut faire la nuance entre émettre une instruction par cycle et exécuter une instruction par cycle. En moyenne, les deux sont identiques. Mais on peut avoir plusieurs instructions en exécution dans les ALU alors qu'on n'en émet qu'une seule à la fois. Dès que des instructions multicycle se manifestent, c'est le cas. Une instruction multicycle s'exécute, mais le processeur continue à émettre des instructions pendant son exécution.
Des optimisations comme l'exécution dans le désordre permettent de profiter de cette possibilité, mais pas totalement. Les processeurs superscalaires sont une optimisation de plus dans cet objectif. Vu qu'on n'ajoute pas d'unités de calcul en plus, les seules modifications sont au niveau de l'amont, dans les unités de chargement, décodage, de renommage et d'émission. Le tampon de réordonnancement/ROB est aussi modifié de manière à ce qu'on puisse émettre et commit plusieurs instructions à la fois.
Exploiter les ALU existantes entraine une sous-utilisation des ports d'émission
[modifier | modifier le wikicode]Une idée assez simple, et passablement inefficace, est d'allouer un port d'émission à chaque ALU déjà présente dans un processeur à pipeline dynamique. Mais voyons ce que cela donne avec un processeur assez réaliste. Le processeur en question contient une ALU spécialisée dans les opérations à un cycle (add/sub/cmp/shift/XOR/NOT/OR/AND/...), un circuit multiplieur/diviseur séparé de l'ALU entière proprement dite, un barrel shifter séparé des autres ALU, une FPU et l'unité mémoire. Les 5 unités permettent d'émettre 5 micro-opérations en même temps, ce qui demande d'ajouter 5 ports d'émission.
Maintenant, posons-nous la question : est-ce que faire ainsi en vaut la peine ? En pratique, il est rare qu'un programme profite de toutes les avals en même temps. Il émet/exécute des micro-opérations sur 1/2 unités de calcul, mais pas sur toutes. Les unités de calcul sont donc sous-utilisées du point de vue de l'émission. Un cas similaire s'était présenté sur les processeurs Intel d'architecture P6. Le processeur était capable d'émettre 5 micro-opérations (deux entières, une flottante, deux mémoire), mais les concepteurs de la puce savaient que le processeur n'en émettait en moyenne que 3. Aussi, il est intéressant de limiter le nombre de ports d'émission, pour ne pas gaspiller de matériel pour des cas très peu fréquents. L'unité d'émission a moins de ports de lecture que l'ALU, certaines ALU doivent partager le même port de lecture.
Mais pourquoi une telle sous-utilisation ? Les raisons à cela sont multiples. La première est liée aux instructions multicycles. Prenons la FPU, par exemple. Imaginons qu'on ait une FPU capable de faire des additions/soustractions/multiplication en 10 cycles. Concrètement, cela signifie qu'on peut émettre une micro-opération flottante tous les 10 cycles. Si la FPU a son propre port d'émission, il sera utilisé uen fois tous les 10 cycles et sera donc sous-utilisé.
Une autre raison est que les programmes exécutent un mix d'instruction qui est différent de ce que les unités de calcul permettent. Par exemple, certains programmes n'exécutent que des opérations entières, sans calculs flottants. Avec eux, le port d'émission de la FPU est inutilisé ou presque. Mais reprenons l'exemple du processeur précédent avec un multiplieur, un décaleur et une unité entière simple. Rares sont les programmes qui ont un nombre égal de décalages, multiplications et additions/soustractions. La plupart des programmes sont majoritairement remplis d'additions, avec des multiplications assez rares et des décalages qui le sont encore plus. Dans ces conditions, le port d'émission de l'ALU entière est sur-utilisé, alors que ceux du multiplieur et du barrel shifter sont sous-utilisés.
En soi, on peut faire avec. Mais le résultat est un gain limité en performance, pour un cout en transistor élevé. Ajouter des ports d'émission n'est pas gratuit. La solution consiste à partager les ports entre plusieurs unités de calcul. Le processeur inclu moins de ports que d'unités de calcul, avec certains ports qui sont reliés à plusieurs unités de calcul. Typiquement, il n'est pas rare qu'une ALU entière et une FPU partagent le même port. De même, les FPU sont souvent éclatées en un additionneur flottant, un multiplieur flottant, et d'autres circuits séparés, mais qui sont reliés au même port. Les circuits multiplieurs ont souvent un port partagé avec barrel shifter ou avec une ALU entière simple, etc. Le résultat de ce partage est que la répartition entre ALU et ports d'émission est assez étranger sur les architectures modernes avec des dizaines d'ALUs.
En faisant cela, on économise beaucoup de circuits pour un cout en performance mineur. Car cout en performance il y a. Par exemple, imaginez que le multiplieur entier partage son port d'émission avec la FPU. Imaginez qu'on se trouve dans une situation où on veut émettre une multiplication et une opération flottante en même temps : impossible ! Au lieu d'émettre les deux instructions en même temps, pendant le même cycle, on devra les émettre l'une à la suite de l'autre, en deux cycles. On perd donc un cycle d'horloge pour une des deux opérations. Mais la situation ne se présente que pour certaines combinaisons de micro-opérations bien précises, qui sont idéalement assez rares si on choisit bien quelles ALU mettre sur telle port.
Les processeurs à double emission FPU/INT/MEM
[modifier | modifier le wikicode]Prenons un cas de processeur simple, avec une ALU entière, une ALU flottante, une unité d'accès mémoire. La plupart des premiers processeurs superscalaires étaient de ce type. Ils sont assez anciens, ce qui fait qu'ils devaient se débrouiller avec peu de circuits dont ils disposaient, typiquement une ALU entière et une FPU, avec de quoi lire/écrire dans le cache. Même si la triple émission était possible, leurs concepteurs ont privilégié la double émission, sans doute pour une raison de cout en transistor. Il y a donc deux unités de calcul qui partagent le même port de lecture sur l'unité d'émission. Deux possibilités sont fréquemment utilisées, pour profiter des synergies entre catégories de micro-opérations.
La première permet d'émettre une micro-opération mémoire en parallèle d'une autre micro-opération. Le processeur a donc deux pipelines séparés : un pour les micro-opérations mémoire, un autre pour les autres micro-opérations. En clair, la FPU et l'ALU sont regroupées ensemble dans un même pipeline. L'inconvénient est que l'unité d'accès mémoire doit incorporer ses propres unités de calcul d'adresse, afin que les calculs d'adresse ne soient pas effectués par l'ALU entière.
La seconde solution permet d'émettre une instruction flottante et une autre instruction. Le processeur a donc deux pipelines séparés : un pour les micro-opérations entières et mémoire, un autre pour les micro-opérations flottantes. Le processeur dispose pour cela d'une FPU séparée de l'ALU entière, mais aussi de banc de registres séparés pour les entiers et les flottants. Le séquenceur devait aussi être conçu pour, naturellement. L'avantage se situe au niveau de la gestion des calculs d'adresse. Les unités de calcul entière peuvent être utilisées pour les calculs d'adresse et transmettre les adresses calculées aux unités mémoire. La technique a été utilisée sur les processeurs POWER 1 et ses dérivés comme le RISC Single Chip.
Les processeurs superscalaires ont souvent plusieurs ALU entières
[modifier | modifier le wikicode]Pour plus de performances, les processeurs superscalaire modernes ajoutent des unités de calcul, qui n'auraient servies à rien sur un processeur sans émission multiple. Sur presque tous les processeurs superscalaires existants, l'unité de calcul entière est dupliquée en plusieurs exemplaires identiques. La raison est qu'un processeur exécute en grosse majorité des additions, soustraction et comparaisons, qui sont prises en charge par l'ALU entière. Avec plusieurs ALU entières, on peut émettre plusieurs opérations de ce type en même temps, ce qui donne un gain en performance assez important. Bien plus important qu'avec les techniques précédentes.
Sur les processeurs disposant d'une ALU simples et d'un multiplieur séparé, les ALU sont souvent dupliqués, mais pas forcément le multiplieur. La raison est que les multiplications sont moins fréquentes que les additions/soustractions et opérations logiques. Si le processeur exécute plusieurs opérations en même temps, il sera rare d'avoir plusieurs multiplications à exécuter en parallèle. Disposer de plusieurs circuits multiplieurs serait donc un cout en circuits qui ne servirait que rarement et n'en vaut pas la chandelle. Par contre, il sera fréquent d'avoir plein d'additions/soustractions/décalages avec éventuellement une multiplication de temps en temps. Typiquement, il n'est pas rare d'avoir une multiplication pour 4/5 additions. Dans ce cas, la solution la plus rentable consiste à dupliquer les ALU simples, mais pas les multiplieurs.
De nombreux processeurs décident de regrouper le circuit multiplieur avec une ALU simple sur le même port d'émission. Une autre solution est de fusionner le multiplieur avec l'ALU qui partage le même port. Les deux donnent le même résultat, ou presque. Un exemple de processeur de ce type est le Power PC 440. Il s'agit d'un processeur double émission qui n'a pas de FPU et se contente de deux unités de calcul entières, d'un circuit multiplieur, et d'une unité mémoire. L'unité mémoire et une ALU entière simple sont regroupées sur le même port d'exécution, sans doute afin d'utiliser l'ALU entière pour les calculs d'adresse. Le second port est relié à une ALU entière capable de faire soit une multiplication, soit une addition, soit les deux, et éventuellement quelques opérations en plus. L'organisation en question permet soit d'émettre un accès mémoire en même temps qu'une opération entière, soit d'émettre deux opérations entières simples, soit d’émettre une multiplication et une addition/soustraction/comparaison. Une organisation simple, mais très efficace !
Le contournement sur les processeurs superscalaires
[modifier | modifier le wikicode]Pour rappel, la technique du contournement (register bypass) permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. il faut aussi des comparateurs pour détecter des dépendances de données.
Les problèmes du contournement sur les CPU avec beaucoup d'ALUs
[modifier | modifier le wikicode]Avec plusieurs unités de calcul, la sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec ! Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup de circuits. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable sur les processeurs modernes, qui ont facilement une dizaine d'unité de calcul.
De plus, la complexité du réseau de contournement (l'ensemble des interconnexions entre ALU) a un cout en terme de rapidité du processeur. Plus il est complexe, plus les données contournées traversent de longs fils, plus leur temps de trajet est long, plus la fréquence du processeur en prend un coup. Diverses techniques permettent de limiter la casse, comme l'usage d'un bus de contournement, mais elle est assez impraticable avec beaucoup d'unités de calcul.
Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. Un simple CPU à exécution dans le désordre, non-superscalaire, a souvent pas mal d'unités de calcul et fait face au même problème. En théorie, un processeur sans exécution dans le désordre ou superscalarité pourrait avoir le problème. Mais en pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant.
La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents agglomérats (cluster). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, l'usage d'agglomérats fait que certaines possibilités de contournement sont perdues, avec la perte de performance qui va avec. Mais la perte en possibilités de contournement vaut bien le gain en fréquence et le cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée.
Les bancs de registre sont aussi adaptés pour le contournement
[modifier | modifier le wikicode]L'usage d'agglomérats peut aussi prendre en compte les interconnections entre unités de calcul et registres. C'est-à-dire que les registres peuvent être agglomérés. Et cela peut se faire de plusieurs façons différentes.
Une première solution, déjà vue dans les chapitres sur la micro-architecture d'un processeur, consiste à découper le banc de registres en plusieurs bancs de registres plus petits. Il faut juste prévoir un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée COPY.
Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le program counter en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat.
Une autre solution duplique le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les lectures en RAM et les résultats des opérations sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Le banc de registre est dupliqué en autant d'exemplaires qu'il y a d'agglomérats. Chaque exemplaire a exactement deux ports de lecture, une par opérande, au lieu de plusieurs dizaines. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres.
Un étude des microarchitectures superscalaires d'Intel
[modifier | modifier le wikicode]Après avoir vu beaucoup de théorie, voyons maintenant comment les microarchitectures Intel et AMD ont implémenté l'exécution superscalaire. Nous allons nous concentrer sur les processeurs Intel pour une raison simple : il y a plus de schémas disponibles sur wikicommons, ce qui me facilite le travail.
Exemple : le Pentium 1/MMX et les pipelines U/V
[modifier | modifier le wikicode]Un exemple de processeur superscalaire est celui du premier Pentium. Le pipeline faisait 5 étages : un étage de chargement/prédiction de branchement, deux étages de décodage, un étage d'exécution et un dernier étage pour l'écriture dans les registres. Une organisation très simple, compliquée uniquement par les instructions x86 CISC.
Le pentium 1 était un processeur à double émission, qui disposait de deux pipelines nommés U et V. On pouvait en tirer parti lorsque deux instructions consécutives pouvaient être exécutées en parallèles, si elles n'avaient pas de dépendances. Chose peu courante, les deux pipelines étaient des pipelines entiers, mais qui n'étaient pas identiques. Les pipelines U et V n'exécutaient pas les mêmes instructions, certaines étaient partagées, d'autres spécifiques au pipeline U, d'autres au pipeline V. Le pipeline U pouvait exécuter toutes les instructions, mais le pipeline V était beaucoup plus limité.
Avant toute chose, précisons que le pipeline U peut faire des calculs flottants, alors que le pipeline V ne fait que des calculs entiers et des branchements. L'unité flottante était sur le port d'émission du pipeline U, idem pour l'unité de calcul vectoriel MMX sur le Pentium MMX. Les branchements sont exécutables dans les deux pipelines, mais on ne peut exécuter une autre opération en parallèle qu'à la condition que le branchement soit exécuté dans le pipeline V. Passons maintenant aux opérations entières.
Les deux pipelines disposaient d'une unité de calcul entière, ainsi que d'une unité de calcul d'adresse, mais elles n'étaient pas identiques. Les deux unités de calcul entière géraient les opérations bit à bit, les additions et soustractions (et les comparaisons, qui sont des soustractions déguisées). Le pipeline U incorporait aussi des circuits pour les multiplications et les divisions, ainsi qu'un barrel shifter pour les décalages/rotations. L'unité de calcul d'adresse du pipeline V ne pouvait effectuer que l'instruction LEA, pas les autres calculs d'adresse.
Les instructions suivantes étaient exécutables dans les deux pipelines, ce qui fait qu'on pouvait en faire deux en même temps :
- l'instruction MOV, dépend du mode d'adressage ;
- les instructions de gestion de la pile PUSH et POP, dépend du mode d'adressage ;
- Les instructions arithmétiques INC, DEC, ADD, SUB ;
- l'instruction de comparaison CMP ;
- les instructions bit à bit AND, OR, XOR ;
- l'instruction de calcul d'adresse LEA ;
- l'instruction NOP, qui ne fait rien.
Les instructions suivantes sont exécutables seulement dans le pipeline U :
- les décalages et rotations, mais seulement pour certains opérandes ou pour certains modes d'adressage.
- les autres instructions arithmétiques entières, comme la multiplication et la division, mais elles ne permettent pas d'exécuter une opération dans le pipeline V en parallèle.
La microarchitecture P6 du Pentium 2/3
[modifier | modifier le wikicode]La microachitecture suivante, nommée P6, était une microarchitecture plus élaborée. Le pipeline faisait 12 étages, dont seuls les trois derniers correspondent au chemin de données. Il s'agissait du premier processeur à exécution dans le désordre de la marque, avec une implémentation basée sur des stations de réservation. Il gérait aussi le renommage de registre, avec un renommage de registre dans le ROB, commandé par une table d'alias.
Le décodage des instructions x86 était géré par plusieurs décodeurs. Il y avait trois décodeurs : deux décodeurs simples, et un décodeur complexe. Les décodeurs simples décodaient les instructions les plus fréquentes, mais aussi les plus simples. Les instructions CISC complexes étaient gérées uniquement par le décodeur complexe, basé sur un microcode. Le processeur était à doubvle émission, du fait que les deux décodeurs simples faisaient le gros du travail, et passaient la main au décodeur microcodé quand aucune instruction ne leur était attribué.
Les stations de réservations étaient regroupées dans une structure centralisée, en sortie de l'unité de renommage. Elles avaient 5 ports d'émission, qui étaient sous-utilisés en pratique. Niveau ALU, on trouve deux ALUs entières, une flottante, une unité pour les instructions SSE et autres, et trois unités pour les accès mémoire (regroupées en une seule unité dans le schéma ci-dessous). Les unités mémoire regroupent une unité de calcul d'adresse pour les lectures, une autre pour les écritures, et une unité pour la gestion des données à écrire. Les unités de calcul d'adresse sont des additionneurs à 4 opérandes, complétement différents des ALU entières. Les ALU entières sont deux unités asymétriques : une ALU simple, et une ALU complexe incorporant un multiplieur. Les deux peuvent exécuter des opérations d'addition, soustraction, comparaison, etc.
La microarchitecture Netburst du Pentium 4
[modifier | modifier le wikicode]La microarchitecture Netburst, utilisée sur le Pentium 4, utilisait un pipeline à 20 étage, augmenté à 32 sur une révision ultérieure. Il a existé quatre révisions de l'architecture : Willamette (180 nm), Northwood (130 nm), Prescott (90 nm) et Cedar Mill (65 nm). Les deux premières avaient un pipeline de 20 étages, les deux suivants avaient 32 étages ! Le grand nombre d'étages permettait d'avoir une fréquence très élevée, mais posait de nombreux problèmes. Vider le pipeline était très long et il fallait une prédiction de branchement au top pour réduire l'impact des mauvaises prédictions. L'unité de prédiction de branchement était une des plus élvoluées pour l'époque. Pour l'époque.
Il dispose d'un cache de trace et a été le seul processeur commercial à en utiliser un. Niveau décodeurs, on retrouve le décodeur lent à base de microcode présent sur les anciennes versions, couplé à un décodeur simple. L'unité de renomage utilise une table d'alias. Le renommage de registres se fait avec un banc de registres physiques. Vous pouvez remarquer dans le schéma suivant la présence de deux files de micro-opérations : une pour les accès mémoire, l'autre pour les autres opérations. Il s'agit bel et bien de deux files d'instructions, pas de fenêtres d'instruction ni de stations de réservation.
Niveau ports d'émission, il y a quatre ports. Un pour les lectures mémoire, un pour les écriture mémoire, et deux autres qui mélangent FPU et ALUs. Le premier port est relié à une ALU complexe et une FPU spécialisée dans les MOV flottants. Le second port est relié à tout le reste : ALU basique barrel shifter, FPU. Fait amusant, les ALU entières étaient cadencées à une fréquence double de celle du processeur, ce qui fait que les files d'émissionsont aussi censées l'être, de même que les scoreboard. Sauf qu'en réalité, les circuits étaient dupliqués : l'un allait à une fréquence double, l'autre allait à la fréquence normale.
Les microarchitectures x86 d'AMD
[modifier | modifier le wikicode]Les architectures AMD ont beaucoup évoluées avec le temps. La toute première était l'architecture K5, qui était capable de décoder 4 instructions différentes par cycle et l'unité d'émission avait 5 ports d'émission. Elle disposait de deux ALU entières, d'une FPU, d'une unité LOAD/STORE pour les accès mémoire et d'une unité de branchement. Il utilisait des stations de réservations dédiées chacune à une unité de calcul.
L'architecture K6 a abandonné les stations de réservation pour passer à une structure centralisée, dont je ne sais pas s'il s'agit d'une fenêtre d’instruction ou d'une station de réservation. Le processeur était capable d'émettre en même 6 micro-opérations, dont deux entières, une flottante, une SIMD, et deux micro-opérations mémoire. Ils disposaient de deux ALU toutes capables de faire des opérations basiques (add/sub/cmp/shifts/...), l'une étant sur le même port que le multiplieur. Cela permet d'exécuter en même temps soit deux additions/soustractions, soit une addition/soustraction en parallèle d'une multiplication. Il s'agit d'un compromis assez intéressant, qui réduit le nombre de ports de l'unité d'émission, sans vraiment réduire les performances.
La microarchitecture K7 des processeurs Athlon était intermédiaire entre les deux précédentes. Elle avait deux files de micro-opérations : une pour les flottants, une autre pour le reste (entiers et adresses). Il y avait trois unités de calcul entières, asymétriques, avec trois ports d'émission associés. Les FPU étaient reliées à deux ports d'émission, leur nombre exact est inconnu. Les unités de calcul entières peuvent aussi être utilisées comme unités de calcul d'adresse. Mais sur ce processeur, le nombre d'unités d'accès mémoire était déséquilibré. Les 3 ALU entières n'étaient pas associées à 3 unités d'accès mémoire, mais seulement 2 vu que le cache était double port.
La microarchitecture suivant, nommée K8, a été suivie par l'architecture K10, assez similaire. Il n'y a pas de K9, qui a été abandonné en cours de développement. Les changements entre les deux sont assez mineures, mais citons le retour à une fenêtre centralisée. Les deux architectures ont beaucoup d'additionneurs : trois ALU entières simples, trois unités de calcul d'adresse dont il n'est pas certain si elles sont distinctes des unités entières, plusieurs unités flottantes spécialisées, et un circuit multiplieur qui partage le port d'une ALU entière. En somme, une microarchitecture conçue pour exécuter beaucoup d'opérations entières simples en parallèle, avec une ALU entière en plus comparé aux précédentes.
Viennent ensuite les microarchitectures Bulldozer, avec quatre révisions. Elle pouvait décoder quatre instructions par cycle dans quatre décodeurs distincts. Là encore, on trouve des fenêtres décentralisées. On trouve un paquet d'unités de calcul flottantes, regroupées dans un cluster. Les entiers sont dans deux clusters séparés, avec chaun deux ALU entières, deux unités de calcul d'adresse, et une unité d'accès mémoire. Les registres entiers sont dupliqués, du fait de l'utilisation de techniques de multithreading matériel que nous n'avons pas encore abordé. Pour simplifier, l'ensemble est équivalent à deux cœurs de processeur avec une unité flottante partagée entre les coeurs.
Les microarchitectures suivantes sont les architecture ZEN 1/2/3/4/5. Elles se ressemblent beaucoup, chacune accumulant les améliorations des précédentes. Mais le cœur de l'architecture reste plus ou moins le même. En passant à la suivante, le nombre de registre virtuel augmente, le branch target buffer augmente en taille, le ROB et les files d'attente grossissent, les caches de micro-opération aussi, les caches grossissent, etc. La microarchitecture Zen 1 est illustrée ci-dessous. Le passage à Zen 2 a ajouté une unité de calcul d'adresse (4 ALU / 3 AGU), le Zen 5 a ajouté deux autres ALU entières et une unité de calcul d'adresse (6 ALU / 4 AGU)